Compare commits
68
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a412ae4fb7 | ||
|
|
9319943f64 | ||
|
|
0a82d61ff8 | ||
|
|
cb9e73bd94 | ||
|
|
16238d39ce | ||
|
|
2219c928e0 | ||
|
|
d36ffc80c2 | ||
|
|
8b6e93d412 | ||
|
|
157b102519 | ||
|
|
cf362548f1 | ||
|
|
378e433086 | ||
|
|
f732528551 | ||
|
|
890eab7dc3 | ||
|
|
5343b65562 | ||
|
|
f56391d88f | ||
|
|
d32bf7a4ab | ||
|
|
e82303c314 | ||
|
|
56e30bf065 | ||
|
|
161d0561e7 | ||
|
|
30931d3f50 | ||
|
|
bd14b3004c | ||
|
|
2c25acbfdd | ||
|
|
50cf3e977a | ||
|
|
bb3a923c43 | ||
|
|
78cac12367 | ||
|
|
5a22ea8df3 | ||
|
|
cb81157216 | ||
|
|
d3257c56ac | ||
|
|
2d56534581 | ||
|
|
6fa4c340b8 | ||
|
|
c3c8662ca7 | ||
|
|
c2a2481313 | ||
|
|
48bbe511b8 | ||
|
|
8d98acb3a4 | ||
|
|
adbfd3bfc6 | ||
|
|
222d0263a4 | ||
|
|
c765a42503 | ||
|
|
4cc1b643d3 | ||
|
|
09b40153e6 | ||
|
|
fc5712d651 | ||
|
|
87072d7781 | ||
|
|
2a502e540a | ||
|
|
e0db01dbf6 | ||
|
|
6a7ffd9b65 | ||
|
|
649c4b3f5b | ||
|
|
7caa316082 | ||
|
|
213b984550 | ||
|
|
68e2444a71 | ||
|
|
af8983c5a7 | ||
|
|
2dde64d1b6 | ||
|
|
b7502c28bf | ||
|
|
fabd86e111 | ||
|
|
77d1e050f0 | ||
|
|
407c366474 | ||
|
|
26e3dad557 | ||
|
|
5bca64898b | ||
|
|
52b294ed22 | ||
|
|
c3ce97236d | ||
|
|
74b22853a9 | ||
|
|
5c780290a8 | ||
|
|
251f8cd87e | ||
|
|
4ee56fb94a | ||
|
|
4bc9ad89fd | ||
|
|
e6ba50d676 | ||
|
|
1431c74fda | ||
|
|
6784bb5600 | ||
|
|
64fdb568b4 | ||
|
|
db957a3441 |
@@ -4,9 +4,6 @@ when:
|
||||
branch: master
|
||||
path:
|
||||
- "ansible/roles/netbird-server/**"
|
||||
# install-netbird-server.yml also applies the netbird-proxy-watchdog role,
|
||||
# so changes to it must redeploy this host too.
|
||||
- "ansible/roles/netbird-proxy-watchdog/**"
|
||||
- "ansible/plays/install-netbird-server.yml"
|
||||
- ".woodpecker/deploy-netbird-server.yml"
|
||||
- event: deployment
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
# Deploy a NetBird reverse-proxy HA cluster member (a second proxy host).
|
||||
# Deploy the NetBird reverse-proxy HA cluster members (the public edge).
|
||||
# Usage: ansible-playbook -i inventory/ plays/install-netbird-proxy.yml
|
||||
#
|
||||
# Prerequisites:
|
||||
@@ -7,12 +7,11 @@
|
||||
# listed under [netbird_proxy] in inventory/pve_hosts.yml. Docker is
|
||||
# installed by the (Arch-aware) docker-install role.
|
||||
# - DNS A/AAAA for the host's own name (e.g. proxy-1.renner.uno) -> its IP.
|
||||
# - Uses the existing vault_netbird_proxy_token (shared with VPS-1) — no new
|
||||
# token to generate; a shared token + same NB_PROXY_DOMAIN forms the cluster.
|
||||
# - AFTER deploy + verify: add this host's IP to the proxy domain so traffic
|
||||
# round-robins across the cluster. Repoint proxy.renner.uno away from its
|
||||
# CNAME to netbird.renner.uno (management host) to A/AAAA records listing
|
||||
# BOTH proxy IPs (VPS-1 + this host).
|
||||
# - Uses the existing vault_netbird_proxy_token (shared by every member) — no
|
||||
# new token to generate; a shared token + same NB_PROXY_DOMAIN forms the
|
||||
# cluster.
|
||||
# - AFTER deploy + verify: add this host's A/AAAA to proxy.renner.uno so
|
||||
# traffic round-robins across the cluster.
|
||||
|
||||
- name: Deploy NetBird reverse-proxy cluster member
|
||||
hosts: netbird_proxy
|
||||
|
||||
@@ -18,4 +18,3 @@
|
||||
- common
|
||||
- docker-install
|
||||
- netbird-server
|
||||
- netbird-proxy-watchdog
|
||||
|
||||
@@ -43,10 +43,10 @@
|
||||
#
|
||||
# WHAT IS NOT, and must not be added:
|
||||
#
|
||||
# vault_netbird_proxy_token LOOKS symmetric — it is in proxy.env on both
|
||||
# the server and the proxies, all Ansible
|
||||
# templates — but the management server does not
|
||||
# validate it from any of them. It is registered
|
||||
# vault_netbird_proxy_token LOOKS symmetric — it is in proxy.env on the
|
||||
# proxies, an Ansible template — but the
|
||||
# management server does not validate it from
|
||||
# any file. It is registered
|
||||
# in the management DATABASE. Rotating it changes
|
||||
# only what the proxies present, so every proxy
|
||||
# is locked out: "proxy auth failed: too many
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
# Self-healing watchdog for the NetBird reverse proxy (VPS-1 and proxy-1).
|
||||
# Self-healing watchdog for the NetBird reverse proxy on the proxy hosts.
|
||||
#
|
||||
# Why it exists: the proxy's embedded NetBird client pins each proxied
|
||||
# domain's route to a specific routing-peer identity. The netbird-ingress
|
||||
@@ -20,9 +20,7 @@ netbird_watchdog_probe_hosts:
|
||||
- home.renner.uno
|
||||
- immich.renner.uno
|
||||
|
||||
# Address the probe connects to. 127.0.0.1 reaches the local proxy on both
|
||||
# members: VPS-1 routes it via Traefik's HostSNI(*) TCP passthrough, proxy-1
|
||||
# binds :443 directly.
|
||||
# Address the probe connects to. The proxy binds :443 on the host directly.
|
||||
netbird_watchdog_probe_address: 127.0.0.1
|
||||
|
||||
netbird_watchdog_container: netbird-proxy
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
# Deploys the reverse-proxy watchdog (see defaults/main.yml for why).
|
||||
|
||||
- name: Ensure curl is installed (probe dependency; proxy-1 is Arch, VPS-1 Debian)
|
||||
- name: Ensure curl is installed (probe dependency)
|
||||
ansible.builtin.package:
|
||||
name: curl
|
||||
state: present
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
# NetBird reverse-proxy HA cluster member (proxy-only host, e.g. proxy-1).
|
||||
# Joins the same proxy cluster as the VPS-1 proxy: identical NB_PROXY_DOMAIN
|
||||
# + the same token. NetBird load-balances/fails the cluster over via DNS
|
||||
# round-robin on the proxy domain (see plays/install-netbird-proxy.yml).
|
||||
# Every member uses the same NB_PROXY_DOMAIN + token. NetBird load-balances/
|
||||
# fails the cluster over via DNS round-robin on the proxy domain (see
|
||||
# plays/install-netbird-proxy.yml).
|
||||
|
||||
# MUST match the netbird-server role's netbird_version — the reverse-proxy
|
||||
# release line has to track the management server it enrolls against.
|
||||
@@ -11,11 +11,13 @@ netbird_version: "0.73.2"
|
||||
# Management server this proxy enrolls against (public API, TLS).
|
||||
netbird_server_host: netbird.renner.uno
|
||||
|
||||
# Proxy domain — MUST be identical across every cluster member.
|
||||
# Proxy domain — MUST be identical across every cluster member, and must NOT
|
||||
# be a subdomain of the management domain (netbird.renner.uno) per NetBird
|
||||
# docs (TLS/routing conflicts).
|
||||
netbird_proxy_domain: "proxy.renner.uno"
|
||||
|
||||
# Reuse VPS-1's proxy token — a shared NB_PROXY_TOKEN across members with the
|
||||
# same NB_PROXY_DOMAIN is a valid HA cluster, so no separate token is needed.
|
||||
# A shared NB_PROXY_TOKEN across members with the same NB_PROXY_DOMAIN is a
|
||||
# valid HA cluster, so one token serves every member.
|
||||
netbird_proxy_token: "{{ vault_netbird_proxy_token }}"
|
||||
|
||||
# Relay auth secret — MUST be identical across every relay instance and the
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
# NetBird reverse-proxy — HA cluster member on a proxy-only host.
|
||||
# Just the reverse-proxy container, bound directly to :443. No Traefik:
|
||||
# unlike VPS-1 (where the proxy shares :443 with management/signal/relay via
|
||||
# SNI routing), this host runs nothing else, so the proxy owns the port and
|
||||
# terminates TLS + answers its own tls-alpn-01 ACME challenges directly.
|
||||
# Just the reverse-proxy container, bound directly to :443. No Traefik: the
|
||||
# proxy owns the port and terminates TLS + answers its own tls-alpn-01 ACME
|
||||
# challenges directly.
|
||||
services:
|
||||
proxy:
|
||||
image: netbirdio/reverse-proxy:{{ netbird_version }}
|
||||
|
||||
@@ -23,9 +23,3 @@ netbird_datastore_key: "{{ vault_netbird_datastore_key }}"
|
||||
|
||||
# Relay secret for WebSocket relay authentication
|
||||
netbird_relay_secret: "{{ vault_netbird_relay_secret }}"
|
||||
|
||||
# Reverse proxy configuration
|
||||
# IMPORTANT: Proxy domain must NOT be a subdomain of the management domain
|
||||
# (netbird.renner.uno) per NetBird docs - causes TLS/routing conflicts
|
||||
netbird_proxy_domain: "proxy.renner.uno"
|
||||
netbird_proxy_token: "{{ vault_netbird_proxy_token }}"
|
||||
|
||||
@@ -16,7 +16,3 @@
|
||||
- name: Restart netbird coturn
|
||||
ansible.builtin.shell: |
|
||||
cd /opt/netbird && docker compose up -d --force-recreate coturn
|
||||
|
||||
- name: Restart netbird proxy
|
||||
ansible.builtin.shell: |
|
||||
cd /opt/netbird && docker compose up -d --force-recreate proxy
|
||||
|
||||
@@ -36,7 +36,6 @@
|
||||
- /opt/netbird/management
|
||||
- /opt/netbird/signal
|
||||
- /opt/netbird/letsencrypt
|
||||
- /opt/netbird/proxy/certs
|
||||
|
||||
- name: Deploy docker-compose.yml
|
||||
ansible.builtin.template:
|
||||
@@ -59,23 +58,83 @@
|
||||
mode: "0644"
|
||||
notify: Restart netbird coturn
|
||||
|
||||
- name: Deploy proxy.env
|
||||
ansible.builtin.template:
|
||||
src: proxy.env.j2
|
||||
dest: /opt/netbird/proxy.env
|
||||
mode: "0644"
|
||||
notify: Restart netbird proxy
|
||||
# The public edge is the proxy hosts (plays/install-netbird-proxy.yml); this
|
||||
# host runs no reverse proxy. The tasks below remove the one it used to run,
|
||||
# its watchdogs first so they cannot restart it mid-removal, and can go once
|
||||
# the role has run against every host that ever carried it.
|
||||
- name: Check for reverse-proxy watchdog units
|
||||
ansible.builtin.stat:
|
||||
path: "/etc/systemd/system/{{ item }}"
|
||||
loop:
|
||||
- netbird-proxy-watchdog.timer
|
||||
- netbird-proxy-flood-watchdog.service
|
||||
register: _proxy_watchdog_units
|
||||
|
||||
- name: Stop and disable reverse-proxy watchdogs
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ item.item }}"
|
||||
state: stopped
|
||||
enabled: false
|
||||
loop: "{{ _proxy_watchdog_units.results }}"
|
||||
loop_control:
|
||||
label: "{{ item.item }}"
|
||||
when: item.stat.exists
|
||||
|
||||
- name: Remove reverse-proxy watchdog units and scripts
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: absent
|
||||
loop:
|
||||
- /etc/systemd/system/netbird-proxy-watchdog.timer
|
||||
- /etc/systemd/system/netbird-proxy-watchdog.service
|
||||
- /etc/systemd/system/netbird-proxy-flood-watchdog.service
|
||||
- /usr/local/bin/netbird-proxy-watchdog
|
||||
- /usr/local/bin/netbird-proxy-flood-watchdog
|
||||
- /run/netbird-proxy-watchdog
|
||||
register: _proxy_watchdog_files
|
||||
|
||||
- name: Reload systemd after removing the watchdog units
|
||||
ansible.builtin.systemd:
|
||||
daemon_reload: true
|
||||
when: _proxy_watchdog_files.changed
|
||||
|
||||
- name: Pull NetBird container images
|
||||
ansible.builtin.shell: |
|
||||
cd /opt/netbird && docker compose pull
|
||||
changed_when: false
|
||||
|
||||
# --remove-orphans removes containers of services no longer in the compose
|
||||
# file (the reverse proxy).
|
||||
- name: Start NetBird containers
|
||||
ansible.builtin.shell: |
|
||||
cd /opt/netbird && docker compose up -d
|
||||
cd /opt/netbird && docker compose up -d --remove-orphans
|
||||
register: docker_compose_result
|
||||
changed_when: "'Started' in docker_compose_result.stdout or 'Creating' in docker_compose_result.stdout"
|
||||
changed_when: >-
|
||||
'Started' in docker_compose_result.stdout or
|
||||
'Creating' in docker_compose_result.stdout or
|
||||
'Removed' in docker_compose_result.stdout or
|
||||
'Removed' in docker_compose_result.stderr
|
||||
|
||||
- name: Remove reverse-proxy volumes
|
||||
ansible.builtin.shell: |
|
||||
set -eu
|
||||
for v in netbird_netbird_proxy_certs netbird_netbird_proxy_data netbird_netbird_proxy_state; do
|
||||
if [ -n "$(docker volume ls -q --filter "name=^${v}$")" ]; then
|
||||
docker volume rm "$v"
|
||||
echo "removed $v"
|
||||
fi
|
||||
done
|
||||
register: _proxy_volumes
|
||||
changed_when: "'removed' in _proxy_volumes.stdout"
|
||||
|
||||
- name: Remove reverse-proxy config and state
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: absent
|
||||
loop:
|
||||
- /opt/netbird/proxy.env
|
||||
- /opt/netbird/proxy
|
||||
- /opt/netbird/proxy-state
|
||||
|
||||
# The management store has no cluster-side backup. Pulling one put a root
|
||||
# key on this host that the cluster held, and this server's own state is
|
||||
|
||||
@@ -187,10 +187,9 @@ services:
|
||||
- "traefik.http.routers.netbird-management.service=netbird-management"
|
||||
- "traefik.http.services.netbird-management.loadbalancer.server.port=33073"
|
||||
- "traefik.http.services.netbird-management.loadbalancer.server.scheme=h2c"
|
||||
# gRPC reverse-proxy service — remote reverse-proxy instances (e.g.
|
||||
# proxy-1) connect here via NB_PROXY_MANAGEMENT_ADDRESS=https://host:443.
|
||||
# The local proxy uses management:33073 directly, so it never needed
|
||||
# this route; remote ones get a 404 without it. Same backend (h2c 33073).
|
||||
# gRPC reverse-proxy service — the proxy hosts (proxy-1, proxy-2)
|
||||
# connect here via NB_PROXY_MANAGEMENT_ADDRESS=https://host:443 and get
|
||||
# a 404 without it. Same backend (h2c 33073).
|
||||
- "traefik.http.routers.netbird-proxyservice.rule=Host(`{{ netbird_server_host }}`) && PathPrefix(`/management.ProxyService/`)"
|
||||
- "traefik.http.routers.netbird-proxyservice.entrypoints=websecure"
|
||||
- "traefik.http.routers.netbird-proxyservice.tls=true"
|
||||
@@ -221,37 +220,6 @@ services:
|
||||
max-size: "500m"
|
||||
max-file: "2"
|
||||
|
||||
# Reverse Proxy Service (per docs.netbird.io/selfhosted/migration/enable-reverse-proxy)
|
||||
proxy:
|
||||
image: netbirdio/reverse-proxy:{{ netbird_version }}
|
||||
container_name: netbird-proxy
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- netbird
|
||||
depends_on:
|
||||
- management
|
||||
env_file:
|
||||
- ./proxy.env
|
||||
volumes:
|
||||
- netbird_proxy_certs:/certs
|
||||
- /opt/netbird/proxy-state:/var/lib/netbird
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.tcp.routers.proxy-passthrough.entrypoints=websecure"
|
||||
- "traefik.tcp.routers.proxy-passthrough.rule=HostSNI(`*`)"
|
||||
- "traefik.tcp.routers.proxy-passthrough.tls.passthrough=true"
|
||||
- "traefik.tcp.routers.proxy-passthrough.service=proxy-tls"
|
||||
- "traefik.tcp.routers.proxy-passthrough.priority=1"
|
||||
- "traefik.tcp.services.proxy-tls.loadbalancer.server.port=8443"
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "500m"
|
||||
max-file: "2"
|
||||
|
||||
networks:
|
||||
netbird:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
netbird_proxy_certs:
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
NB_PROXY_DOMAIN={{ netbird_proxy_domain }}
|
||||
NB_PROXY_TOKEN={{ netbird_proxy_token }}
|
||||
NB_PROXY_MANAGEMENT_ADDRESS=http://management:33073
|
||||
NB_PROXY_ALLOW_INSECURE=true
|
||||
NB_PROXY_ADDRESS=:8443
|
||||
NB_PROXY_ACME_CERTIFICATES=true
|
||||
NB_PROXY_ACME_CHALLENGE_TYPE=tls-alpn-01
|
||||
NB_PROXY_CERTIFICATE_DIRECTORY=/certs
|
||||
@@ -0,0 +1,976 @@
|
||||
# Paperclip Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Deploy Paperclip (server-local agent runs, option B) into the homelab cluster behind an Authentik gate, with CNPG Postgres and OpenBao-sourced secrets.
|
||||
|
||||
**Architecture:** One Flux `Kustomization` (`paperclip`) applies `kubernetes/apps/paperclip/`: a single-replica Deployment on a ceph-block PVC, CNPG `pclp-pg` (PG17) with barman backups to Hetzner, and a Traefik Ingress that backends only the `paperclip-gate` Authentik outpost. Namespace, baseline CNP, ClusterSecretStore allowlist and image automation live in the existing `infrastructure` tree.
|
||||
|
||||
**Tech Stack:** Flux v2 (kustomize-controller, image-reflector v1.2.5), CNPG + barman-cloud plugin, External Secrets → OpenBao, Cilium CNP, Traefik, Authentik blueprints.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-09-25-paperclip-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Worktree: `/home/krenner/source/homelab-worktrees/paperclip`, branch `feat/paperclip`. Never edit the main checkout.
|
||||
- Commits as claude-bot: `git -c user.name='Claude Bot' -c user.email='claude-bot@renner.uno' commit …`, message ends with `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
|
||||
- Push: `git -c credential.helper='store --file=/home/krenner/.config/claude-bot/creds' push …`. PR via `tea pr create --login claude-bot`. Never self-merge.
|
||||
- One Bash command per call: no `&&`, `;`, `|` chaining, no `2>/dev/null`, no `python -c`.
|
||||
- Namespace `paperclip`; hostname `paperclip.renner.uno`; LAN-only (no `dns.renner.uno/public` label).
|
||||
- Image `ghcr.io/paperclipai/paperclip`, tag `nightly`, digest-pinned, `digestReflectionPolicy: Always`.
|
||||
- Postgres: CNPG `pclp-pg`, `ghcr.io/cloudnative-pg/postgresql:17`, 2 instances, `ceph-block`.
|
||||
- PVC `paperclip-data`, `ceph-block`, 20Gi, mounted at `/paperclip`.
|
||||
- OpenBao key `apps/paperclip` with fields `BETTER_AUTH_SECRET`, `PAPERCLIP_TOOL_ACTION_SIGNING_SECRET`, `PAPERCLIP_SECRETS_MASTER_KEY` (64-char hex), `CLAUDE_CODE_OAUTH_TOKEN`.
|
||||
- Env: `PAPERCLIP_DEPLOYMENT_MODE=authenticated`, `PAPERCLIP_DEPLOYMENT_EXPOSURE=private`, `PAPERCLIP_PUBLIC_URL=https://paperclip.renner.uno`, `HOST=0.0.0.0`, `PORT=3100`.
|
||||
- Manifest comments: one terse factual line; rationale goes in commit messages/PR.
|
||||
- No `kubectl apply`/`exec`/`rollout restart` against the live cluster; Flux applies after merge. Server-side `--dry-run=server` is allowed.
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. **Pod replaced mid agent run** (image bump, secret rotation via Reloader, node drain) — `Recreate` kills in-flight runs; expected: Paperclip marks them failed/recoverable on restart, not wedged. Covered in Task 9 Step 6.
|
||||
2. **Shared OAuth token expired or missing** — expected: agent runs fail with a visible auth error in Paperclip, server stays healthy. Covered in Task 9 Step 7.
|
||||
3. **Direct access bypassing the gate** from another namespace pod — expected: connection refused/dropped by CNP. Covered in Task 9 Step 4.
|
||||
4. **Unauthenticated LAN request** to `paperclip.renner.uno` — expected: redirect to Authentik, never Paperclip's UI. Covered in Task 9 Step 3.
|
||||
5. **Nightly image with a breaking DB migration** — expected: a base backup exists before the first bump lands. Covered in Task 9 Step 5.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Upstream facts that decide two manifest values
|
||||
|
||||
No repo changes; produces two decisions consumed by Task 6.
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `SIGNUP_DISABLED` (`"true"` or `"false"`), `CONFIG_PARTIAL_OK` (yes/no), `ENTRYPOINT_NEEDS_ROOT` (yes/no).
|
||||
|
||||
- [ ] **Step 1: Sign-up vs instance claim.** Read upstream `server/src/auth/better-auth.ts`, `server/src/routes/auth.ts`, and the claim flow for `authenticated`+`private` (search `claim` under `server/src`):
|
||||
|
||||
Run: `gh api repos/paperclipai/paperclip/contents/server/src/routes/auth.ts -H "Accept: application/vnd.github.raw"`
|
||||
|
||||
Decide: if the first-admin claim creates the account through a path that ignores `disableSignUp` (e.g. invite/claim token handler calling the adapter directly), `SIGNUP_DISABLED="true"`. Otherwise `SIGNUP_DISABLED="false"` — the Authentik gate is the access control, and a follow-up PR flips it to `"true"` after the admin is claimed.
|
||||
|
||||
- [ ] **Step 2: Partial config.json.** Read `server/src/config.ts` and the config-file loader it calls (search `PAPERCLIP_CONFIG` / `readConfigFile`).
|
||||
|
||||
Run: `gh api repos/paperclipai/paperclip/contents/server/src/config.ts -H "Accept: application/vnd.github.raw"`
|
||||
|
||||
Decide `CONFIG_PARTIAL_OK=yes` if a file containing only `{"telemetry":{"enabled":false}}` parses (every other section optional/defaulted). If not, stop and report to the user — do not invent config fields.
|
||||
|
||||
- [ ] **Step 3: Entrypoint privilege.**
|
||||
|
||||
Run: `gh api repos/paperclipai/paperclip/contents/docker-entrypoint.sh -H "Accept: application/vnd.github.raw"`
|
||||
|
||||
`ENTRYPOINT_NEEDS_ROOT=yes` if it calls `usermod`/`groupmod`/`chown`/`gosu`. Record the result for Task 6.
|
||||
|
||||
- [ ] **Step 4: Anonymous health response.**
|
||||
|
||||
Run: `gh api repos/paperclipai/paperclip/contents/server/src/routes/health.ts -H "Accept: application/vnd.github.raw"`
|
||||
|
||||
Confirm `GET /api/health` returns HTTP 200 to an anonymous caller when healthy (probes use it). If it returns 401 anonymously, use a `tcpSocket` probe on 3100 in Task 6 instead.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Namespace, baseline CNP, ClusterSecretStore allowlist
|
||||
|
||||
**Files:**
|
||||
- Modify: `kubernetes/infrastructure/namespaces/namespaces.yaml` (append)
|
||||
- Create: `kubernetes/infrastructure/network-policies/paperclip-ns.yaml`
|
||||
- Modify: `kubernetes/infrastructure/network-policies/kustomization.yaml`
|
||||
- Modify: `kubernetes/infrastructure/post-crds/openbao-clustersecretstore.yaml` (sorted list)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: namespace `paperclip`; ESO may reference `openbao` from it.
|
||||
|
||||
- [ ] **Step 1: Append the namespace** to the end of `namespaces.yaml`:
|
||||
|
||||
```yaml
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: paperclip
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create `paperclip-ns.yaml`** (copy of `paperless-ns.yaml`'s `default-deny-ingress`, ns renamed, paperless-only CNPs dropped):
|
||||
|
||||
```yaml
|
||||
# Traefik is not admitted: the only entry is the paperclip-gate outpost (apps/paperclip/network-policy.yaml).
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: default-deny-ingress
|
||||
namespace: paperclip
|
||||
spec:
|
||||
endpointSelector: {}
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": paperclip
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": database
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": monitoring
|
||||
- fromEntities:
|
||||
- host
|
||||
- remote-node
|
||||
- fromEndpoints:
|
||||
- matchExpressions:
|
||||
- key: io.kubernetes.pod.namespace
|
||||
operator: Exists
|
||||
icmps:
|
||||
- fields:
|
||||
- { type: 0, family: IPv4 }
|
||||
- { type: 3, family: IPv4 }
|
||||
- { type: 8, family: IPv4 }
|
||||
- { type: 11, family: IPv4 }
|
||||
- { type: 12, family: IPv4 }
|
||||
- { type: 1, family: IPv6 }
|
||||
- { type: 2, family: IPv6 }
|
||||
- { type: 3, family: IPv6 }
|
||||
- { type: 4, family: IPv6 }
|
||||
- { type: 128, family: IPv6 }
|
||||
- { type: 129, family: IPv6 }
|
||||
- { type: 133, family: IPv6 }
|
||||
- { type: 134, family: IPv6 }
|
||||
- { type: 135, family: IPv6 }
|
||||
- { type: 136, family: IPv6 }
|
||||
- { type: 143, family: IPv6 }
|
||||
egress:
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": paperclip
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": authentik
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": ingress
|
||||
app.kubernetes.io/name: traefik
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": rook-ceph
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": kube-system
|
||||
k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- toEntities:
|
||||
- host
|
||||
- remote-node
|
||||
- world
|
||||
- toEndpoints:
|
||||
- matchExpressions:
|
||||
- key: io.kubernetes.pod.namespace
|
||||
operator: Exists
|
||||
icmps:
|
||||
- fields:
|
||||
- { type: 0, family: IPv4 }
|
||||
- { type: 3, family: IPv4 }
|
||||
- { type: 8, family: IPv4 }
|
||||
- { type: 11, family: IPv4 }
|
||||
- { type: 12, family: IPv4 }
|
||||
- { type: 1, family: IPv6 }
|
||||
- { type: 2, family: IPv6 }
|
||||
- { type: 3, family: IPv6 }
|
||||
- { type: 4, family: IPv6 }
|
||||
- { type: 128, family: IPv6 }
|
||||
- { type: 129, family: IPv6 }
|
||||
- { type: 133, family: IPv6 }
|
||||
- { type: 134, family: IPv6 }
|
||||
- { type: 135, family: IPv6 }
|
||||
- { type: 136, family: IPv6 }
|
||||
- { type: 143, family: IPv6 }
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Register it** — in `network-policies/kustomization.yaml` add ` - paperclip-ns.yaml` directly after ` - paperless-ns.yaml`.
|
||||
|
||||
- [ ] **Step 4: Allowlist the namespace** — in `openbao-clustersecretstore.yaml` insert ` - paperclip` between ` - monitoring` and ` - paperless` (list is sorted).
|
||||
|
||||
- [ ] **Step 5: Render check.**
|
||||
|
||||
Run: `kubectl kustomize kubernetes/infrastructure/network-policies`
|
||||
Expected: output contains `namespace: paperclip`, exit 0.
|
||||
|
||||
Run: `kubectl apply --dry-run=server -f kubernetes/infrastructure/post-crds/openbao-clustersecretstore.yaml`
|
||||
Expected: `clustersecretstore.external-secrets.io/openbao configured (server dry run)`.
|
||||
|
||||
- [ ] **Step 6: Commit** `feat(paperclip): namespace, baseline CNP and OpenBao allowlist`.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: OpenBao secret `apps/paperclip`
|
||||
|
||||
Out-of-repo; must exist before merge or the ExternalSecret never becomes Ready.
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: OpenBao KV `apps/paperclip` with the four fields in Global Constraints.
|
||||
|
||||
- [ ] **Step 1:** Ask wiki-synth for the claude-bot OpenBao write procedure (note `claude-bot-openbao-write-only-identity`): login method, KV mount, exact `bao kv put`/`patch` syntax for an `apps/<name>` key. Use exactly what it returns.
|
||||
|
||||
- [ ] **Step 2: Write the generated fields** as claude-bot (write-only identity — never read values back). Values generated inline by the command:
|
||||
|
||||
`BETTER_AUTH_SECRET` = `openssl rand -hex 32`, `PAPERCLIP_TOOL_ACTION_SIGNING_SECRET` = `openssl rand -hex 32`, `PAPERCLIP_SECRETS_MASTER_KEY` = `openssl rand -hex 32` (64 hex chars = 32 bytes, accepted format).
|
||||
|
||||
Generate each with a separate `openssl rand -hex 32` call and pass it directly into the `bao kv put` from Step 1; do not echo or log them.
|
||||
|
||||
- [ ] **Step 3: Ask the user** to add the shared token themselves (never handle it): "Run `claude setup-token`, then `bao kv patch <mount>/apps/paperclip CLAUDE_CODE_OAUTH_TOKEN=<token>`." Wait for confirmation.
|
||||
|
||||
- [ ] **Step 4: Verify field names only** (no values): `bao kv metadata get <mount>/apps/paperclip` shows a current version ≥2. If claude-bot cannot read metadata, ask the user to confirm the four keys exist.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Image automation + initial digest
|
||||
|
||||
**Files:**
|
||||
- Create: `kubernetes/infrastructure/image-automation/paperclip.yaml`
|
||||
- Modify: `kubernetes/infrastructure/image-automation/kustomization.yaml`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: ImagePolicy `flux-system:paperclip`; `INITIAL_DIGEST` (`sha256:…`) for Task 6.
|
||||
|
||||
- [ ] **Step 1: Create `paperclip.yaml`:**
|
||||
|
||||
```yaml
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImageRepository
|
||||
metadata:
|
||||
name: paperclip
|
||||
namespace: flux-system
|
||||
spec:
|
||||
image: ghcr.io/paperclipai/paperclip
|
||||
interval: 1h
|
||||
---
|
||||
# Upstream publishes no semver tags; track the nightly tag by digest.
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImagePolicy
|
||||
metadata:
|
||||
name: paperclip
|
||||
namespace: flux-system
|
||||
spec:
|
||||
imageRepositoryRef:
|
||||
name: paperclip
|
||||
digestReflectionPolicy: Always
|
||||
filterTags:
|
||||
pattern: '^nightly$'
|
||||
policy:
|
||||
alphabetical:
|
||||
order: asc
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Register** — add ` - paperclip.yaml` after ` - paperless-ngx.yaml` in `image-automation/kustomization.yaml`.
|
||||
|
||||
- [ ] **Step 3: Server dry-run.**
|
||||
|
||||
Run: `kubectl apply --dry-run=server -f kubernetes/infrastructure/image-automation/paperclip.yaml`
|
||||
Expected: both objects `created (server dry run)`; no `unknown field "digestReflectionPolicy"` error.
|
||||
|
||||
- [ ] **Step 4: Resolve the current nightly digest.**
|
||||
|
||||
The workstation has `docker` only (no skopeo/crane/buildx, checked 2026-09-25); `docker manifest inspect` lists per-platform digests but not the index digest, so pull once:
|
||||
|
||||
Run: `docker pull ghcr.io/paperclipai/paperclip:nightly`
|
||||
Run: `docker image inspect --format '{{index .RepoDigests 0}}' ghcr.io/paperclipai/paperclip:nightly`
|
||||
Expected: `ghcr.io/paperclipai/paperclip@sha256:<64 hex>`. Record the `sha256:…` part as `INITIAL_DIGEST`.
|
||||
Run: `docker image rm ghcr.io/paperclipai/paperclip:nightly`
|
||||
|
||||
- [ ] **Step 5: Commit** `feat(paperclip): Flux image automation tracks the nightly digest`.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Postgres (CNPG) and secrets in the app directory
|
||||
|
||||
**Files:**
|
||||
- Create: `kubernetes/apps/paperclip/cluster.yaml`, `objectstore.yaml`, `scheduled-backup.yaml`, `hetzner-creds-externalsecret.yaml`, `externalsecret.yaml`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: Secret `pclp-pg-app` (CNPG, key `uri`), Service `pclp-pg-rw`, Secret `paperclip-secrets`.
|
||||
|
||||
- [ ] **Step 1: `cluster.yaml`:**
|
||||
|
||||
```yaml
|
||||
apiVersion: postgresql.cnpg.io/v1
|
||||
kind: Cluster
|
||||
metadata:
|
||||
name: pclp-pg
|
||||
namespace: paperclip
|
||||
annotations:
|
||||
kustomize.toolkit.fluxcd.io/prune: disabled # data guard
|
||||
spec:
|
||||
instances: 2
|
||||
smartShutdownTimeout: 10
|
||||
stopDelay: 60
|
||||
# PG17: upstream's external Postgres target (docker/docker-compose.yml).
|
||||
imageName: ghcr.io/cloudnative-pg/postgresql:17
|
||||
storage:
|
||||
storageClass: ceph-block
|
||||
size: 10Gi
|
||||
plugins:
|
||||
- name: barman-cloud.cloudnative-pg.io
|
||||
isWALArchiver: true
|
||||
parameters:
|
||||
barmanObjectName: pclp-pg-store
|
||||
bootstrap:
|
||||
initdb:
|
||||
database: paperclip
|
||||
owner: paperclip
|
||||
postgresql:
|
||||
parameters:
|
||||
shared_buffers: "256MB"
|
||||
max_connections: "200"
|
||||
checkpoint_timeout: "15min"
|
||||
max_wal_size: "2GB"
|
||||
wal_compression: "zstd"
|
||||
archive_timeout: "30min"
|
||||
synchronous:
|
||||
method: any
|
||||
number: 1
|
||||
dataDurability: preferred
|
||||
resources:
|
||||
requests:
|
||||
cpu: 200m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
memory: 1Gi
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `objectstore.yaml`:**
|
||||
|
||||
```yaml
|
||||
apiVersion: barmancloud.cnpg.io/v1
|
||||
kind: ObjectStore
|
||||
metadata:
|
||||
name: pclp-pg-store
|
||||
namespace: paperclip
|
||||
annotations:
|
||||
kustomize.toolkit.fluxcd.io/prune: disabled # backup target
|
||||
spec:
|
||||
configuration:
|
||||
destinationPath: s3://renner-uno-k8s-cnpg/pclp-pg
|
||||
endpointURL: https://fsn1.your-objectstorage.com
|
||||
s3Credentials:
|
||||
accessKeyId:
|
||||
name: hetzner-s3-creds
|
||||
key: ACCESS_KEY_ID
|
||||
secretAccessKey:
|
||||
name: hetzner-s3-creds
|
||||
key: ACCESS_SECRET_KEY
|
||||
region:
|
||||
name: hetzner-s3-creds
|
||||
key: REGION
|
||||
wal:
|
||||
compression: gzip
|
||||
data:
|
||||
compression: gzip
|
||||
retentionPolicy: "30d"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: `scheduled-backup.yaml`:**
|
||||
|
||||
```yaml
|
||||
apiVersion: postgresql.cnpg.io/v1
|
||||
kind: ScheduledBackup
|
||||
metadata:
|
||||
name: pclp-pg-daily
|
||||
namespace: paperclip
|
||||
annotations:
|
||||
kustomize.toolkit.fluxcd.io/prune: disabled # backup schedule
|
||||
spec:
|
||||
schedule: "0 45 2 * * *"
|
||||
immediate: true
|
||||
backupOwnerReference: self
|
||||
cluster:
|
||||
name: pclp-pg
|
||||
method: plugin
|
||||
pluginConfiguration:
|
||||
name: barman-cloud.cloudnative-pg.io
|
||||
```
|
||||
|
||||
- [ ] **Step 4: `hetzner-creds-externalsecret.yaml`:**
|
||||
|
||||
```yaml
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: hetzner-s3-creds
|
||||
namespace: paperclip
|
||||
annotations:
|
||||
kustomize.toolkit.fluxcd.io/prune: disabled # barman S3 creds
|
||||
spec:
|
||||
refreshInterval: 1h
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: hetzner-s3-creds
|
||||
dataFrom:
|
||||
- extract:
|
||||
key: infrastructure/hetzner-s3
|
||||
```
|
||||
|
||||
- [ ] **Step 5: `externalsecret.yaml`:**
|
||||
|
||||
```yaml
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: paperclip-secrets
|
||||
namespace: paperclip
|
||||
spec:
|
||||
refreshInterval: 10m
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: paperclip-secrets
|
||||
dataFrom:
|
||||
- extract:
|
||||
key: apps/paperclip
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit** `feat(paperclip): CNPG pclp-pg with barman backups and OpenBao secrets`. (Rendered together with Task 6's kustomization.)
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Server workload, storage, ingress, network policy
|
||||
|
||||
**Files:**
|
||||
- Create: `kubernetes/apps/paperclip/pvc.yaml`, `paperclip.yaml`, `gate-service.yaml`, `ingress.yaml`, `network-policy.yaml`, `kustomization.yaml`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `pclp-pg-app` (`uri`), `paperclip-secrets`, ImagePolicy `flux-system:paperclip`, `INITIAL_DIGEST` (Task 4), `SIGNUP_DISABLED` / `ENTRYPOINT_NEEDS_ROOT` / probe choice (Task 1).
|
||||
- Produces: pods labelled `app.kubernetes.io/name: paperclip` listening on 3100; Service `paperclip:3100`; ExternalName `ak-outpost-paperclip-gate:9000`.
|
||||
|
||||
- [ ] **Step 1: `pvc.yaml`:**
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: paperclip-data
|
||||
namespace: paperclip
|
||||
annotations:
|
||||
kustomize.toolkit.fluxcd.io/prune: disabled # data guard
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
storageClassName: ceph-block
|
||||
resources:
|
||||
requests:
|
||||
storage: 20Gi
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `paperclip.yaml`** — replace `<INITIAL_DIGEST>` with the Task 4 value and `<SIGNUP_DISABLED>` with the Task 1 value. If Task 1 found `ENTRYPOINT_NEEDS_ROOT=no`, replace the container `securityContext` with `runAsNonRoot: true`, `runAsUser: 1000`, `runAsGroup: 1000`, `allowPrivilegeEscalation: false`, `capabilities: {drop: ["ALL"]}`. If Task 1 chose `tcpSocket`, replace each `httpGet` block with `tcpSocket: {port: http}`.
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: paperclip
|
||||
namespace: paperclip
|
||||
labels:
|
||||
app.kubernetes.io/name: paperclip
|
||||
annotations:
|
||||
secret.reloader.stakater.com/reload: paperclip-secrets
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: paperclip
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: paperclip
|
||||
spec:
|
||||
enableServiceLinks: false
|
||||
securityContext:
|
||||
fsGroup: 1000
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
initContainers:
|
||||
# Telemetry is only configurable in config.json.
|
||||
- name: disable-telemetry
|
||||
image: ghcr.io/paperclipai/paperclip:nightly@<INITIAL_DIGEST> # {"$imagepolicy": "flux-system:paperclip"}
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
set -eu
|
||||
f=/paperclip/instances/default/config.json
|
||||
mkdir -p "$(dirname "$f")"
|
||||
if [ -s "$f" ]; then
|
||||
jq '.telemetry.enabled = false' "$f" > "$f.tmp"
|
||||
else
|
||||
printf '%s\n' '{"telemetry":{"enabled":false}}' > "$f.tmp"
|
||||
fi
|
||||
mv "$f.tmp" "$f"
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /paperclip
|
||||
containers:
|
||||
- name: paperclip
|
||||
image: ghcr.io/paperclipai/paperclip:nightly@<INITIAL_DIGEST> # {"$imagepolicy": "flux-system:paperclip"}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3100
|
||||
env:
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: pclp-pg-app
|
||||
key: uri
|
||||
- name: HOST
|
||||
value: "0.0.0.0"
|
||||
- name: PORT
|
||||
value: "3100"
|
||||
- name: PAPERCLIP_DEPLOYMENT_MODE
|
||||
value: authenticated
|
||||
- name: PAPERCLIP_DEPLOYMENT_EXPOSURE
|
||||
value: private
|
||||
- name: PAPERCLIP_PUBLIC_URL
|
||||
value: https://paperclip.renner.uno
|
||||
- name: PAPERCLIP_AUTH_DISABLE_SIGN_UP
|
||||
value: "<SIGNUP_DISABLED>"
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: paperclip-secrets
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /paperclip
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: http
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 60
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: http
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 10
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: http
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 1Gi
|
||||
limits:
|
||||
memory: 4Gi
|
||||
securityContext:
|
||||
runAsNonRoot: false
|
||||
allowPrivilegeEscalation: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
add: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID"]
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: paperclip-data
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: paperclip
|
||||
namespace: paperclip
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: paperclip
|
||||
ports:
|
||||
- name: http
|
||||
port: 3100
|
||||
targetPort: http
|
||||
```
|
||||
|
||||
- [ ] **Step 3: `gate-service.yaml`:**
|
||||
|
||||
```yaml
|
||||
# ExternalName mirror so the Ingress can backend the paperclip-gate outpost in the authentik namespace.
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ak-outpost-paperclip-gate
|
||||
namespace: paperclip
|
||||
spec:
|
||||
type: ExternalName
|
||||
externalName: ak-outpost-paperclip-gate.authentik.svc.cluster.local
|
||||
ports:
|
||||
- port: 9000
|
||||
targetPort: 9000
|
||||
```
|
||||
|
||||
- [ ] **Step 4: `ingress.yaml`:**
|
||||
|
||||
```yaml
|
||||
# LAN-only (no dns.renner.uno/public label); backends the gate outpost, never the Service.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: paperclip
|
||||
namespace: paperclip
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts:
|
||||
- paperclip.renner.uno
|
||||
secretName: paperclip-tls
|
||||
rules:
|
||||
- host: paperclip.renner.uno
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: ak-outpost-paperclip-gate
|
||||
port:
|
||||
number: 9000
|
||||
```
|
||||
|
||||
- [ ] **Step 5: `network-policy.yaml`** (Traefik pod-side port is 8443, verified via `kubectl get pods -n ingress -l app.kubernetes.io/name=traefik`):
|
||||
|
||||
```yaml
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: paperclip-gate-ingress
|
||||
namespace: paperclip
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: paperclip
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": authentik
|
||||
goauthentik.io/outpost-name: paperclip-gate
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "3100"
|
||||
protocol: TCP
|
||||
---
|
||||
# Covers the server and every agent it spawns (same pod).
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: paperclip-egress
|
||||
namespace: paperclip
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: paperclip
|
||||
egress:
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": paperclip
|
||||
cnpg.io/cluster: pclp-pg
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "5432"
|
||||
protocol: TCP
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": kube-system
|
||||
k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
# Gitea, Woodpecker, HA, Loki via their *.renner.uno names.
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": ingress
|
||||
app.kubernetes.io/name: traefik
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8443"
|
||||
protocol: TCP
|
||||
- toEntities:
|
||||
- kube-apiserver
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "6443"
|
||||
protocol: TCP
|
||||
- toEntities:
|
||||
- world
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "443"
|
||||
protocol: TCP
|
||||
```
|
||||
|
||||
- [ ] **Step 6: `kustomization.yaml`:**
|
||||
|
||||
```yaml
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- externalsecret.yaml
|
||||
- hetzner-creds-externalsecret.yaml
|
||||
- cluster.yaml
|
||||
- objectstore.yaml
|
||||
- scheduled-backup.yaml
|
||||
- pvc.yaml
|
||||
- paperclip.yaml
|
||||
- gate-service.yaml
|
||||
- ingress.yaml
|
||||
- network-policy.yaml
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Render check.**
|
||||
|
||||
Run: `kubectl kustomize kubernetes/apps/paperclip`
|
||||
Expected: exit 0; output contains `kind: Cluster`, `kind: Deployment`, `claimName: paperclip-data`, and no literal `<INITIAL_DIGEST>` or `<SIGNUP_DISABLED>`.
|
||||
|
||||
Run: `grep -rn "<INITIAL_DIGEST>\|<SIGNUP_DISABLED>" kubernetes/apps/paperclip`
|
||||
Expected: no output, exit 1.
|
||||
|
||||
- [ ] **Step 8: Commit** `feat(paperclip): server Deployment, PVC, gated Ingress and CNPs`.
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Authentik gate
|
||||
|
||||
**Files:**
|
||||
- Modify: `kubernetes/apps/authentik/blueprints-configmap.yaml` (insert before the line ` # navidrome-gate: the Navidrome web UI.`)
|
||||
- Create: `kubernetes/apps/authentik/paperclip-gate-egress.yaml`
|
||||
- Modify: `kubernetes/apps/authentik/kustomization.yaml`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: outpost pods labelled `goauthentik.io/outpost-name: paperclip-gate`, Service `ak-outpost-paperclip-gate.authentik:9000`.
|
||||
|
||||
- [ ] **Step 1: Insert blueprint entries** (same indentation as the neighbouring `wiki-gate` block):
|
||||
|
||||
```yaml
|
||||
# paperclip-gate: Paperclip UI; the CNP admits only this outpost. Paperclip keeps its own login behind it.
|
||||
- id: paperclip-users-group
|
||||
model: authentik_core.group
|
||||
identifiers:
|
||||
name: Paperclip Users
|
||||
state: present
|
||||
attrs:
|
||||
is_superuser: false
|
||||
users:
|
||||
- !Find [authentik_core.user, [username, k.renner]]
|
||||
|
||||
- id: paperclip-gate-provider
|
||||
model: authentik_providers_proxy.proxyprovider
|
||||
identifiers:
|
||||
name: paperclip-gate
|
||||
state: present
|
||||
attrs:
|
||||
authentication_flow: !Find [authentik_flows.flow, [slug, passkey-passwordless-authentication]]
|
||||
authorization_flow: !Find [authentik_flows.flow, [slug, passkey-stepup-authorization]]
|
||||
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
|
||||
mode: proxy
|
||||
internal_host: http://paperclip.paperclip.svc.cluster.local:3100
|
||||
internal_host_ssl_validation: false
|
||||
external_host: https://paperclip.renner.uno
|
||||
cookie_domain: renner.uno
|
||||
access_token_validity: hours=24
|
||||
refresh_token_validity: days=30
|
||||
intercept_header_auth: true
|
||||
|
||||
- model: authentik_core.application
|
||||
identifiers:
|
||||
slug: paperclip-gate
|
||||
state: present
|
||||
attrs:
|
||||
name: Paperclip
|
||||
slug: paperclip-gate
|
||||
provider: !KeyOf paperclip-gate-provider
|
||||
meta_launch_url: https://paperclip.renner.uno
|
||||
meta_description: "Paperclip agent orchestration"
|
||||
|
||||
- model: authentik_policies.policybinding
|
||||
identifiers:
|
||||
target: !Find [authentik_core.application, [slug, paperclip-gate]]
|
||||
group: !KeyOf paperclip-users-group
|
||||
order: 0
|
||||
state: present
|
||||
attrs:
|
||||
enabled: true
|
||||
negate: false
|
||||
|
||||
- model: authentik_outposts.outpost
|
||||
identifiers:
|
||||
name: paperclip-gate
|
||||
state: present
|
||||
attrs:
|
||||
type: proxy
|
||||
service_connection: !KeyOf k8s-conn
|
||||
providers:
|
||||
- !KeyOf paperclip-gate-provider
|
||||
config:
|
||||
log_level: info
|
||||
authentik_host: "http://authentik-server.authentik.svc.cluster.local"
|
||||
authentik_host_insecure: false
|
||||
authentik_host_browser: "https://auth.renner.uno/"
|
||||
refresh_interval: "minutes=5"
|
||||
container_image: null
|
||||
object_naming_template: "ak-outpost-%(name)s"
|
||||
kubernetes_namespace: authentik
|
||||
kubernetes_replicas: 1
|
||||
kubernetes_service_type: ClusterIP
|
||||
kubernetes_disabled_components: ["ingress"]
|
||||
kubernetes_image_pull_secrets: []
|
||||
kubernetes_ingress_annotations: {}
|
||||
kubernetes_ingress_class_name: null
|
||||
kubernetes_ingress_secret_name: "authentik-outpost-tls"
|
||||
kubernetes_json_patches: null
|
||||
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `paperclip-gate-egress.yaml`:**
|
||||
|
||||
```yaml
|
||||
# Lets the paperclip-gate outpost reach the Paperclip server; scoped to this outpost's pods.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: paperclip-gate-egress
|
||||
namespace: authentik
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
goauthentik.io/outpost-name: paperclip-gate
|
||||
egress:
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": paperclip
|
||||
app.kubernetes.io/name: paperclip
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "3100"
|
||||
protocol: TCP
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Register** — add ` - paperclip-gate-egress.yaml` after ` - paperless-gate-egress.yaml` in `apps/authentik/kustomization.yaml`.
|
||||
|
||||
- [ ] **Step 4: Render + dry-run.**
|
||||
|
||||
Run: `kubectl kustomize kubernetes/apps/authentik`
|
||||
Expected: exit 0 (valid YAML incl. the blueprint ConfigMap).
|
||||
|
||||
Run: `kubectl apply --dry-run=server -f kubernetes/apps/authentik/paperclip-gate-egress.yaml`
|
||||
Expected: `ciliumnetworkpolicy.cilium.io/paperclip-gate-egress created (server dry run)`.
|
||||
|
||||
- [ ] **Step 5: Commit** `feat(authentik): paperclip-gate outpost and Paperclip Users group`.
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Flux Kustomization
|
||||
|
||||
**Files:**
|
||||
- Create: `kubernetes/clusters/homelab/paperclip.yaml`
|
||||
|
||||
- [ ] **Step 1:**
|
||||
|
||||
```yaml
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: paperclip
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 10m
|
||||
retryInterval: 1m
|
||||
timeout: 20m
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: flux-system
|
||||
path: ./kubernetes/apps/paperclip
|
||||
prune: true
|
||||
wait: true
|
||||
dependsOn:
|
||||
- name: infrastructure
|
||||
- name: infrastructure-crds
|
||||
- name: infrastructure-ceph-cluster
|
||||
- name: infrastructure-cnpg-barman
|
||||
healthCheckExprs:
|
||||
- apiVersion: postgresql.cnpg.io/v1
|
||||
kind: Cluster
|
||||
failed: "has(status.phase) && status.phase.startsWith('Failed')"
|
||||
current: "has(status.phase) && (status.phase == 'Cluster in healthy state' || status.readyInstances > 0)"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Server dry-run.**
|
||||
|
||||
Run: `kubectl apply --dry-run=server -f kubernetes/clusters/homelab/paperclip.yaml`
|
||||
Expected: `kustomization.kustomize.toolkit.fluxcd.io/paperclip created (server dry run)`.
|
||||
|
||||
- [ ] **Step 3: Commit** `feat(paperclip): Flux Kustomization`.
|
||||
|
||||
---
|
||||
|
||||
### Task 9: PR, merge by user, live verification
|
||||
|
||||
- [ ] **Step 1: Push and open the PR** (Task 3 must be complete).
|
||||
|
||||
Run: `git -c credential.helper='store --file=/home/krenner/.config/claude-bot/creds' push -u origin feat/paperclip`
|
||||
Run: `tea pr create --login claude-bot --base master --head feat/paperclip --title "feat(paperclip): deploy Paperclip (server-local runs, trial)" --description "<summary of spec decisions + link to spec + bootstrap steps>\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)"`
|
||||
Then wait for the user to merge. Do not merge.
|
||||
|
||||
- [ ] **Step 2: Reconciliation.** After merge:
|
||||
|
||||
Run: `flux get kustomizations paperclip`
|
||||
Expected: `Ready True`. If False, read `kubectl describe kustomization paperclip -n flux-system` and the failing object's events; diagnose, don't poke.
|
||||
|
||||
Run: `kubectl get cluster -n paperclip pclp-pg`
|
||||
Expected: `Cluster in healthy state`, 2 ready instances.
|
||||
|
||||
Run: `kubectl get pods -n paperclip -l app.kubernetes.io/name=paperclip`
|
||||
Expected: `1/1 Running`.
|
||||
|
||||
- [ ] **Step 3: Gate enforced (Review Focus 4).**
|
||||
|
||||
Run: `curl -sS -o /dev/null -w '%{http_code} %{redirect_url}\n' https://paperclip.renner.uno/`
|
||||
Expected: `302` redirecting to `https://paperclip.renner.uno/outpost.goauthentik.io/…` or `auth.renner.uno`, never `200`.
|
||||
|
||||
- [ ] **Step 4: Gate not bypassable (Review Focus 3).**
|
||||
|
||||
`default` has no CNPs (verified 2026-09-25), so its egress is open and only paperclip's ingress policy can block the probe.
|
||||
|
||||
Run: `kubectl get ciliumnetworkpolicies -n default`
|
||||
Expected: `No resources found` (else pick another CNP-free namespace).
|
||||
|
||||
Run: `kubectl run cnp-probe -n default --rm -i --restart=Never --image=curlimages/curl:8.10.1 -- curl -sS -m 5 http://paperclip.paperclip.svc.cluster.local:3100/api/health`
|
||||
Expected: timeout (exit 28), no HTTP response.
|
||||
|
||||
- [ ] **Step 5: First backup (Review Focus 5).**
|
||||
|
||||
Run: `kubectl get backup -n paperclip`
|
||||
Expected: one `pclp-pg-daily-…` with phase `completed`.
|
||||
|
||||
- [ ] **Step 6: Image policy.**
|
||||
|
||||
Run: `kubectl get imagepolicy -n flux-system paperclip -o jsonpath='{.status.latestRef}'`
|
||||
Expected: `nightly` tag with a `sha256:` digest.
|
||||
|
||||
- [ ] **Step 7: Hand-over to the user (browser steps, theirs):**
|
||||
1. Open `https://paperclip.renner.uno`, pass Authentik, claim the instance as admin.
|
||||
2. Create a test agent with the `claude-local` adapter and a trivial task ("reply with OK"); it must complete with the shared token (Review Focus 2: if it fails, report the run's error text — an expired token must show as an auth error, not a hang).
|
||||
3. If `SIGNUP_DISABLED` was `"false"`, open the follow-up PR flipping `PAPERCLIP_AUTH_DISABLE_SIGN_UP` to `"true"` once the admin is claimed.
|
||||
4. Review Focus 1 is observed on the first nightly bump: check the interrupted-run status in the UI.
|
||||
|
||||
- [ ] **Step 8: Wiki.** Route the verified Paperclip facts (execution topology, env vars, image tags, digest-reflection usage) through wiki-ingest → wiki-distill once the deployment has proven out.
|
||||
@@ -0,0 +1,181 @@
|
||||
# Paperclip in the homelab cluster — design
|
||||
|
||||
Date: 2026-09-25
|
||||
Status: draft, awaiting review
|
||||
|
||||
## Goal
|
||||
|
||||
Run [Paperclip](https://github.com/paperclipai/paperclip) (MIT, agent-orchestration
|
||||
server + UI) in the homelab cluster for real use: its agents do actual work against
|
||||
homelab systems. This spec covers the **platform** only. Bot identities and their
|
||||
credentials are out of scope (see "Deferred").
|
||||
|
||||
## Decisions
|
||||
|
||||
| Topic | Decision |
|
||||
|---|---|
|
||||
| Execution model | **B — server-local runs** (upstream default): agent CLIs are child processes of the server, in the server container. Trial only. |
|
||||
| Upgrade path | If Paperclip is kept: **C** — `@paperclipai/plugin-kubernetes` sandbox provider + kubernetes-sigs agent-sandbox controller (`agents.x-k8s.io`), per-run Pods and Secrets. Separate spec. |
|
||||
| Claude auth | **One shared Claude subscription AI connection**, created once via Paperclip's local sign-in (`claude auth login` into `ai-local-logins/<id>`); Paperclip stores and refreshes it. Server-env `CLAUDE_CODE_OAUTH_TOKEN` is not used: `stripAiAuthBindings` blanks it for managed runs, and upstream accepts no pasted setup-token (the device-code flow needs a Daytona sandbox). |
|
||||
| Bot credentials | Out of scope. Future "AIR-Manager" Paperclip role handles permission requests. |
|
||||
| Exposure | LAN-only, behind an Authentik forward-auth gate, then Paperclip's own login. |
|
||||
|
||||
### Known trade-off of B (accepted for the trial)
|
||||
|
||||
Upstream `runChildProcess()` spawns every local adapter with
|
||||
`{...sanitizeInheritedPaperclipEnv(process.env), ...opts.env}`; the sanitizer strips only
|
||||
`PAPERCLIP_*` keys. Every agent therefore sees `DATABASE_URL`, `BETTER_AUTH_SECRET`,
|
||||
the shared filesystem (including every other agent's workspace and the
|
||||
`ai-local-logins` credentials) and every agent-scoped secret it can read from disk. Under B, no bot may hold
|
||||
a credential we would not tolerate any other bot using. Per-bot isolation is C's job.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
LAN client ─▶ Traefik ─▶ ExternalName svc ─▶ ak-outpost-paperclip-gate (authentik ns)
|
||||
│ forward-auth OK
|
||||
▼
|
||||
paperclip Deployment (ns paperclip, 1 replica)
|
||||
├─ server :3100 (+ agent CLIs as child processes)
|
||||
├─ PVC /paperclip (ceph-block)
|
||||
└─▶ pclp-pg-rw (CNPG, PG17, 2 instances) ─▶ barman ▶ Hetzner S3
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
All modeled on `kubernetes/apps/paperless/` unless stated.
|
||||
|
||||
### Namespace and baseline policy
|
||||
- `Namespace paperclip` in `kubernetes/infrastructure/namespaces/namespaces.yaml`.
|
||||
- Baseline default-deny CNP `kubernetes/infrastructure/network-policies/paperclip-ns.yaml`
|
||||
(copy of `paperless-ns.yaml`, renamed).
|
||||
- Add `paperclip` to the `openbao` ClusterSecretStore namespace allowlist.
|
||||
|
||||
### Flux
|
||||
- `kubernetes/clusters/homelab/paperclip.yaml`: Flux `Kustomization`,
|
||||
`path: ./kubernetes/apps/paperclip`, `prune: true`, `wait: true`, same `dependsOn` and
|
||||
CNPG `healthCheckExprs` as `paperless.yaml`.
|
||||
- `kubernetes/apps/paperclip/kustomization.yaml`: plain resource list.
|
||||
|
||||
### Server Deployment (`paperclip.yaml`)
|
||||
- Image `ghcr.io/paperclipai/paperclip:nightly@sha256:<digest>` with the Flux
|
||||
image-automation marker for digest updates.
|
||||
- `replicas: 1`, `strategy: Recreate` (single writer on an RWO PVC).
|
||||
- Starts as root: upstream `docker-entrypoint.sh` remaps the `node` user, chowns
|
||||
`/paperclip`, then drops to uid 1000 via `gosu`. Caps limited to CHOWN,
|
||||
DAC_OVERRIDE, FOWNER, SETGID, SETUID; `fsGroup: 1000`.
|
||||
- `automountServiceAccountToken: false` (all agents share the pod filesystem).
|
||||
- Port 3100; `Service paperclip` ClusterIP :3100.
|
||||
- Probes: startup and readiness on `GET /api/health` with `Host: localhost` (the
|
||||
private-hostname guard rejects the pod IP); liveness is a TCP check, since
|
||||
`/api/health` runs `SELECT 1` and a DB outage must not restart the server.
|
||||
- Env:
|
||||
- `DATABASE_URL` assembled from CNPG `pclp-pg-app` Secret (`uri` key).
|
||||
- `HOST=0.0.0.0`, `PORT=3100`
|
||||
- `PAPERCLIP_DEPLOYMENT_MODE=authenticated`
|
||||
- `PAPERCLIP_DEPLOYMENT_EXPOSURE=private`
|
||||
- `PAPERCLIP_PUBLIC_URL=https://paperclip.renner.uno`
|
||||
- `PAPERCLIP_AUTH_DISABLE_SIGN_UP=false` until the first admin is claimed: the
|
||||
claim (`/bootstrap/claim`) requires an already signed-in Better Auth account.
|
||||
A follow-up flips it to `true`.
|
||||
- `PAPERCLIP_TELEMETRY_DISABLED=1` (read by `resolveTelemetryConfig`).
|
||||
- `PAPERCLIP_API_URL=http://paperclip.paperclip.svc.cluster.local:3100` plus
|
||||
`PAPERCLIP_ALLOWED_HOSTNAMES=paperclip.paperclip.svc.cluster.local`: upstream
|
||||
otherwise hands agents the public URL, and the gate (`intercept_header_auth`)
|
||||
rejects their bearer tokens. The Service name also works for sandbox pods under
|
||||
option C; agent JWTs still authenticate each call.
|
||||
- `envFrom: secretRef paperclip-secrets`
|
||||
- Reloader: `secret.reloader.stakater.com/reload: paperclip-secrets`.
|
||||
|
||||
### Storage
|
||||
- `PVC paperclip-data`, `ceph-block`, 20Gi, mounted at `/paperclip` (`PAPERCLIP_HOME`
|
||||
and `HOME`): instance config, workspaces, repo checkouts, per-agent `claude-runtime`
|
||||
dirs.
|
||||
|
||||
### Postgres
|
||||
- CNPG `Cluster pclp-pg`: 2 instances, `imageName: ghcr.io/cloudnative-pg/postgresql:17`,
|
||||
`ceph-block` 10Gi, `bootstrap.initdb {database: paperclip, owner: paperclip}`,
|
||||
barman-cloud plugin as WAL archiver.
|
||||
- `ObjectStore pclp-pg-store` → `s3://renner-uno-k8s-cnpg/pclp-pg`, creds via the
|
||||
existing `hetzner-s3-creds` ExternalSecret pattern.
|
||||
- `ScheduledBackup` nightly, `method: plugin`, `backupOwnerReference: self`.
|
||||
- `kustomize.toolkit.fluxcd.io/prune: disabled` on Cluster, ObjectStore, ScheduledBackup.
|
||||
- PG17 deviates from the cluster-wide PG16 convention because upstream's external
|
||||
Postgres target is `postgres:17` (`docker/docker-compose.yml`) and its embedded default
|
||||
is PG18.
|
||||
|
||||
### Secrets
|
||||
One ExternalSecret `paperclip-secrets` ← OpenBao `apps/paperclip` (whole-object
|
||||
extract):
|
||||
- `BETTER_AUTH_SECRET`
|
||||
- `PAPERCLIP_TOOL_ACTION_SIGNING_SECRET`
|
||||
- `PAPERCLIP_SECRETS_MASTER_KEY` (64-char hex). Held in OpenBao, not on the PVC, so
|
||||
encrypted agent secrets in Postgres survive PVC loss.
|
||||
|
||||
### Ingress and SSO
|
||||
- Hostname `paperclip.renner.uno`, Traefik `Ingress`, `cert-manager.io/cluster-issuer:
|
||||
letsencrypt-cluster`, **no** `dns.renner.uno/public` label (LAN-only).
|
||||
- Authentik gate, per the paperless pattern:
|
||||
- blueprint entries in `kubernetes/apps/authentik/blueprints-configmap.yaml`:
|
||||
proxy provider `paperclip-gate` (`internal_host:
|
||||
http://paperclip.paperclip.svc.cluster.local:3100`, `external_host:
|
||||
https://paperclip.renner.uno`), application, group "Paperclip Users", policy binding;
|
||||
- outpost `ak-outpost-paperclip-gate`;
|
||||
- `kubernetes/apps/authentik/paperclip-gate-egress.yaml` (no `auth.renner.uno`
|
||||
outpost Ingress: like wiki/shelly gates, the outpost serves its own callback on
|
||||
`paperclip.renner.uno`);
|
||||
- `ExternalName` Service in `paperclip` ns pointing at the outpost; the app Ingress
|
||||
targets it on :9000.
|
||||
- Paperclip's own email/password login stays on (no header/OIDC login upstream).
|
||||
|
||||
### Network policy (`kubernetes/apps/paperclip/network-policy.yaml`)
|
||||
Covers the server **and every agent** (same pod under B).
|
||||
- Ingress to the app pod: only from the `paperclip-gate` outpost (by label) on 3100.
|
||||
- Egress from the app pod:
|
||||
- `pclp-pg` pods :5432
|
||||
- kube-dns :53
|
||||
- `ingress` namespace (Traefik) :8443 (pod-side websecure port) — reaches Gitea, Woodpecker, HA, Loki by their
|
||||
`*.renner.uno` names
|
||||
- `kube-apiserver` entity :6443
|
||||
- `world` TCP :443 (Anthropic API, GitHub, npm)
|
||||
- No SSH egress; git over HTTPS only. The namespace baseline
|
||||
(`paperclip-ns.yaml`) also restricts `world` to TCP 443 — Cilium unions allows
|
||||
across CNPs, so an unrestricted baseline would override this list.
|
||||
- Known gap (house baseline): pods in `monitoring`/`database` and other pods in
|
||||
`paperclip` can reach :3100 without the gate; Paperclip's own login still applies.
|
||||
|
||||
### Image automation
|
||||
`kubernetes/infrastructure/image-automation/paperclip.yaml`: `ImageRepository`
|
||||
(`ghcr.io/paperclipai/paperclip`) + `ImagePolicy` filtering tag `^nightly$` with
|
||||
`digestReflectionPolicy: Always` (image-reflector-controller v1.2.5 in cluster; field
|
||||
available since v0.35.0). First use of digest reflection in this repo.
|
||||
|
||||
## Guardrails
|
||||
- No bot receives cluster-admin or any binding in `flux-system`, `authentik`, `openbao`.
|
||||
- Git work goes through PRs; bots get no merge rights on protected branches.
|
||||
- Nothing is granted under B that we would not tolerate any agent misusing.
|
||||
|
||||
## Bootstrap (one-time)
|
||||
1. Place `apps/paperclip` values in OpenBao (the three generated secrets).
|
||||
2. Merge; Flux deploys with sign-up enabled.
|
||||
3. Sign up through the gate and claim the instance as admin; then set
|
||||
`PAPERCLIP_AUTH_DISABLE_SIGN_UP=true`.
|
||||
4. In onboarding "Connect a model", choose Claude subscription and run the shown login
|
||||
**as the node user** (the entrypoint makes exec sessions root, and root-owned 0600
|
||||
credentials are unreadable to the server):
|
||||
`kubectl exec -it -n paperclip deploy/paperclip -- gosu node sh -c 'export CLAUDE_CONFIG_DIR=<shown path> && mkdir -p "$CLAUDE_CONFIG_DIR" && claude auth login'`
|
||||
Repeat after loss of the PVC or database.
|
||||
|
||||
## Verification
|
||||
- Flux Kustomization `paperclip` Ready; CNPG `pclp-pg` healthy; first ScheduledBackup
|
||||
completes.
|
||||
- `curl https://paperclip.renner.uno/api/health` through the gate returns healthy after
|
||||
Authentik login; unauthenticated request redirects to `auth.renner.uno`.
|
||||
- Direct pod access from another namespace is denied (CNP).
|
||||
- A test agent (claude-local) completes a trivial task using the shared Claude connection.
|
||||
- Flux ImagePolicy reports a `nightly@sha256:` digest.
|
||||
|
||||
## Deferred
|
||||
- **Option C** (sandboxed runs) — required before any per-bot credential isolation.
|
||||
- **AIR-Manager** role and all bot identities/credentials.
|
||||
- Public exposure.
|
||||
@@ -122,7 +122,7 @@ spec:
|
||||
claimName: audiobookshelf-metadata
|
||||
- name: books
|
||||
persistentVolumeClaim:
|
||||
claimName: audiobookshelf-books
|
||||
claimName: audiobookshelf-books-bulk
|
||||
- name: le-ca
|
||||
emptyDir:
|
||||
medium: Memory
|
||||
|
||||
@@ -35,14 +35,14 @@ spec:
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: audiobookshelf-books
|
||||
name: audiobookshelf-books-bulk
|
||||
namespace: audiobookshelf
|
||||
annotations:
|
||||
kustomize.toolkit.fluxcd.io/prune: disabled
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteMany
|
||||
storageClassName: ceph-filesystem
|
||||
storageClassName: ceph-filesystem-bulk
|
||||
resources:
|
||||
requests:
|
||||
storage: 300Gi
|
||||
|
||||
@@ -1036,6 +1036,220 @@ data:
|
||||
kubernetes_ingress_secret_name: "authentik-outpost-tls"
|
||||
kubernetes_json_patches: null
|
||||
|
||||
# paperclip-gate: Paperclip UI; the CNP admits only this outpost. Paperclip keeps its own login behind it.
|
||||
- id: paperclip-users-group
|
||||
model: authentik_core.group
|
||||
identifiers:
|
||||
name: Paperclip Users
|
||||
state: present
|
||||
attrs:
|
||||
is_superuser: false
|
||||
users:
|
||||
- !Find [authentik_core.user, [username, k.renner]]
|
||||
|
||||
- id: paperclip-gate-provider
|
||||
model: authentik_providers_proxy.proxyprovider
|
||||
identifiers:
|
||||
name: paperclip-gate
|
||||
state: present
|
||||
attrs:
|
||||
authentication_flow: !Find [authentik_flows.flow, [slug, passkey-passwordless-authentication]]
|
||||
authorization_flow: !Find [authentik_flows.flow, [slug, passkey-stepup-authorization]]
|
||||
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
|
||||
mode: proxy
|
||||
internal_host: http://paperclip.paperclip.svc.cluster.local:3100
|
||||
internal_host_ssl_validation: false
|
||||
external_host: https://paperclip.renner.uno
|
||||
cookie_domain: renner.uno
|
||||
access_token_validity: hours=24
|
||||
refresh_token_validity: days=30
|
||||
intercept_header_auth: true
|
||||
|
||||
- model: authentik_core.application
|
||||
identifiers:
|
||||
slug: paperclip-gate
|
||||
state: present
|
||||
attrs:
|
||||
name: Paperclip
|
||||
slug: paperclip-gate
|
||||
provider: !KeyOf paperclip-gate-provider
|
||||
meta_launch_url: https://paperclip.renner.uno
|
||||
meta_description: "Paperclip agent orchestration"
|
||||
|
||||
- model: authentik_policies.policybinding
|
||||
identifiers:
|
||||
target: !Find [authentik_core.application, [slug, paperclip-gate]]
|
||||
group: !KeyOf paperclip-users-group
|
||||
order: 0
|
||||
state: present
|
||||
attrs:
|
||||
enabled: true
|
||||
negate: false
|
||||
|
||||
- model: authentik_outposts.outpost
|
||||
identifiers:
|
||||
name: paperclip-gate
|
||||
state: present
|
||||
attrs:
|
||||
type: proxy
|
||||
service_connection: !KeyOf k8s-conn
|
||||
providers:
|
||||
- !KeyOf paperclip-gate-provider
|
||||
config:
|
||||
log_level: info
|
||||
authentik_host: "http://authentik-server.authentik.svc.cluster.local"
|
||||
authentik_host_insecure: false
|
||||
authentik_host_browser: "https://auth.renner.uno/"
|
||||
refresh_interval: "minutes=5"
|
||||
container_image: null
|
||||
object_naming_template: "ak-outpost-%(name)s"
|
||||
kubernetes_namespace: authentik
|
||||
kubernetes_replicas: 1
|
||||
kubernetes_service_type: ClusterIP
|
||||
kubernetes_disabled_components: ["ingress"]
|
||||
kubernetes_image_pull_secrets: []
|
||||
kubernetes_ingress_annotations: {}
|
||||
kubernetes_ingress_class_name: null
|
||||
kubernetes_ingress_secret_name: "authentik-outpost-tls"
|
||||
kubernetes_json_patches: null
|
||||
|
||||
# navidrome-gate: the Navidrome web UI. Navidrome auto-creates the
|
||||
# X-Authentik-Username user on first login; the first user is admin.
|
||||
# /rest and /share bypass the gate at the Traefik router.
|
||||
- id: navidrome-gate-provider
|
||||
model: authentik_providers_proxy.proxyprovider
|
||||
identifiers:
|
||||
name: navidrome-gate
|
||||
state: present
|
||||
attrs:
|
||||
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
|
||||
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
|
||||
mode: proxy
|
||||
internal_host: http://navidrome.navidrome.svc.cluster.local
|
||||
internal_host_ssl_validation: false
|
||||
external_host: https://navidrome.renner.uno
|
||||
cookie_domain: renner.uno
|
||||
access_token_validity: hours=24
|
||||
refresh_token_validity: days=30
|
||||
intercept_header_auth: true
|
||||
|
||||
- model: authentik_core.application
|
||||
identifiers:
|
||||
slug: navidrome-gate
|
||||
state: present
|
||||
attrs:
|
||||
name: Navidrome
|
||||
slug: navidrome-gate
|
||||
provider: !KeyOf navidrome-gate-provider
|
||||
meta_launch_url: https://navidrome.renner.uno
|
||||
meta_description: "Music streaming (Subsonic)"
|
||||
|
||||
- model: authentik_outposts.outpost
|
||||
identifiers:
|
||||
name: navidrome-gate
|
||||
state: present
|
||||
attrs:
|
||||
type: proxy
|
||||
service_connection: !KeyOf k8s-conn
|
||||
providers:
|
||||
- !KeyOf navidrome-gate-provider
|
||||
config:
|
||||
log_level: info
|
||||
authentik_host: "http://authentik-server.authentik.svc.cluster.local"
|
||||
authentik_host_insecure: false
|
||||
authentik_host_browser: "https://auth.renner.uno/"
|
||||
refresh_interval: "minutes=5"
|
||||
container_image: null
|
||||
object_naming_template: "ak-outpost-%(name)s"
|
||||
kubernetes_namespace: authentik
|
||||
kubernetes_replicas: 1
|
||||
kubernetes_service_type: ClusterIP
|
||||
kubernetes_disabled_components: ["ingress"]
|
||||
kubernetes_image_pull_secrets: []
|
||||
kubernetes_ingress_annotations: {}
|
||||
kubernetes_ingress_class_name: null
|
||||
kubernetes_ingress_secret_name: "authentik-outpost-tls"
|
||||
kubernetes_json_patches: null
|
||||
|
||||
# lidarr-gate: Lidarr runs with AuthenticationMethod=External, so the gate
|
||||
# is its only access control.
|
||||
- id: music-librarians-group
|
||||
model: authentik_core.group
|
||||
identifiers:
|
||||
name: Music Librarians
|
||||
state: present
|
||||
attrs:
|
||||
is_superuser: false
|
||||
# Membership is owned here; blueprint apply reverts UI edits.
|
||||
users:
|
||||
- !Find [authentik_core.user, [username, k.renner]]
|
||||
|
||||
- id: lidarr-gate-provider
|
||||
model: authentik_providers_proxy.proxyprovider
|
||||
identifiers:
|
||||
name: lidarr-gate
|
||||
state: present
|
||||
attrs:
|
||||
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
|
||||
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
|
||||
mode: proxy
|
||||
internal_host: http://lidarr.navidrome.svc.cluster.local
|
||||
internal_host_ssl_validation: false
|
||||
external_host: https://lidarr.renner.uno
|
||||
cookie_domain: renner.uno
|
||||
access_token_validity: hours=24
|
||||
refresh_token_validity: days=30
|
||||
intercept_header_auth: true
|
||||
|
||||
- model: authentik_core.application
|
||||
identifiers:
|
||||
slug: lidarr-gate
|
||||
state: present
|
||||
attrs:
|
||||
name: Lidarr
|
||||
slug: lidarr-gate
|
||||
provider: !KeyOf lidarr-gate-provider
|
||||
meta_launch_url: https://lidarr.renner.uno
|
||||
meta_description: "Music library gaps: discographies, Spotify artists"
|
||||
|
||||
- model: authentik_policies.policybinding
|
||||
identifiers:
|
||||
target: !Find [authentik_core.application, [slug, lidarr-gate]]
|
||||
group: !KeyOf music-librarians-group
|
||||
order: 0
|
||||
state: present
|
||||
attrs:
|
||||
enabled: true
|
||||
negate: false
|
||||
failure_result: false
|
||||
|
||||
- model: authentik_outposts.outpost
|
||||
identifiers:
|
||||
name: lidarr-gate
|
||||
state: present
|
||||
attrs:
|
||||
type: proxy
|
||||
service_connection: !KeyOf k8s-conn
|
||||
providers:
|
||||
- !KeyOf lidarr-gate-provider
|
||||
config:
|
||||
log_level: info
|
||||
authentik_host: "http://authentik-server.authentik.svc.cluster.local"
|
||||
authentik_host_insecure: false
|
||||
authentik_host_browser: "https://auth.renner.uno/"
|
||||
refresh_interval: "minutes=5"
|
||||
container_image: null
|
||||
object_naming_template: "ak-outpost-%(name)s"
|
||||
kubernetes_namespace: authentik
|
||||
kubernetes_replicas: 1
|
||||
kubernetes_service_type: ClusterIP
|
||||
kubernetes_disabled_components: ["ingress"]
|
||||
kubernetes_image_pull_secrets: []
|
||||
kubernetes_ingress_annotations: {}
|
||||
kubernetes_ingress_class_name: null
|
||||
kubernetes_ingress_secret_name: "authentik-outpost-tls"
|
||||
kubernetes_json_patches: null
|
||||
|
||||
- model: authentik_outposts.outpost
|
||||
identifiers:
|
||||
name: authentik Embedded Outpost
|
||||
|
||||
@@ -7,6 +7,7 @@ resources:
|
||||
- helmrelease.yaml
|
||||
- paperless-gate-ingress.yaml
|
||||
- paperless-gate-egress.yaml
|
||||
- paperclip-gate-egress.yaml
|
||||
- vscode-gate-egress.yaml
|
||||
- shelly-manager-gate-egress.yaml
|
||||
- esphome-gate-egress.yaml
|
||||
@@ -17,6 +18,8 @@ resources:
|
||||
- wordpress-gate-egress.yaml
|
||||
- llama-gate-egress.yaml
|
||||
- wiki-gate-egress.yaml
|
||||
- navidrome-gate-egress.yaml
|
||||
- lidarr-gate-egress.yaml
|
||||
- ldap-outpost-cert.yaml
|
||||
- ldap-outpost-lb.yaml
|
||||
- ldap-outpost-cnp.yaml
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Lets the lidarr-gate outpost reach Lidarr; the matching ingress rule is in
|
||||
# network-policies/navidrome-ns.yaml.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: lidarr-gate-egress
|
||||
namespace: authentik
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
goauthentik.io/outpost-name: lidarr-gate
|
||||
egress:
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": navidrome
|
||||
app.kubernetes.io/name: lidarr
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8686"
|
||||
protocol: TCP
|
||||
@@ -0,0 +1,20 @@
|
||||
# Lets the navidrome-gate outpost reach Navidrome; the matching ingress rule
|
||||
# is in network-policies/navidrome-ns.yaml.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: navidrome-gate-egress
|
||||
namespace: authentik
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
goauthentik.io/outpost-name: navidrome-gate
|
||||
egress:
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": navidrome
|
||||
app.kubernetes.io/name: navidrome
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "4533"
|
||||
protocol: TCP
|
||||
@@ -0,0 +1,19 @@
|
||||
# Lets the paperclip-gate outpost reach the Paperclip server; scoped to this outpost's pods.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: paperclip-gate-egress
|
||||
namespace: authentik
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
goauthentik.io/outpost-name: paperclip-gate
|
||||
egress:
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": paperclip
|
||||
app.kubernetes.io/name: paperclip
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "3100"
|
||||
protocol: TCP
|
||||
@@ -16,6 +16,9 @@ resources:
|
||||
# health broker credential: OpenBao-backed + Class-A rotated (first of the
|
||||
# eight users to migrate off mosquitto-bootstrap.yaml).
|
||||
- mosquitto-health-credentials-externalsecret.yaml
|
||||
# open3e-int broker credential: open3e-esp32 gateway on the Vitocal internal
|
||||
# CAN; OpenBao-backed + Class-A rotated.
|
||||
- mosquitto-open3e-int-credentials-externalsecret.yaml
|
||||
- mosquitto-config.yaml
|
||||
- mosquitto-certificate.yaml
|
||||
- mosquitto-service.yaml
|
||||
|
||||
@@ -12,7 +12,7 @@ metadata:
|
||||
#
|
||||
# Listed per-Secret rather than with the blanket auto annotation, so that
|
||||
# enrolling another broker user is a visible edit to this line.
|
||||
secret.reloader.stakater.com/reload: mosquitto-health-credentials
|
||||
secret.reloader.stakater.com/reload: mosquitto-health-credentials,mosquitto-open3e-int-credentials
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
@@ -78,6 +78,7 @@ spec:
|
||||
mosquitto_passwd -b /mosquitto/auth/passwd admin "$ADMIN_PASSWORD"
|
||||
mosquitto_passwd -b /mosquitto/auth/passwd sml2mqtt "$SML2MQTT_PASSWORD"
|
||||
mosquitto_passwd -b /mosquitto/auth/passwd open3e "$OPEN3E_PASSWORD"
|
||||
mosquitto_passwd -b /mosquitto/auth/passwd open3e-int "$OPEN3E_INT_PASSWORD"
|
||||
mosquitto_passwd -b /mosquitto/auth/passwd evcc "$EVCC_PASSWORD"
|
||||
mosquitto_passwd -b /mosquitto/auth/passwd health "$HEALTH_PASSWORD"
|
||||
mosquitto_passwd -b /mosquitto/auth/passwd zigbee2mqtt "$ZIGBEE2MQTT_PASSWORD"
|
||||
@@ -124,6 +125,12 @@ spec:
|
||||
user open3e
|
||||
topic readwrite open3e/#
|
||||
|
||||
# open3e-esp32 gateway on the Vitocal internal CAN: own subtree
|
||||
# under open3e/ so edge-1's listener on open3e/cmnd never sees
|
||||
# its commands.
|
||||
user open3e-int
|
||||
topic readwrite open3e/int/#
|
||||
|
||||
user evcc
|
||||
topic read open3e/#
|
||||
topic readwrite evcc/#
|
||||
@@ -187,6 +194,13 @@ spec:
|
||||
secretKeyRef:
|
||||
name: mosquitto-open3e-credentials
|
||||
key: password
|
||||
# open3e-int: OpenBao-backed + Class-A rotated (see
|
||||
# mosquitto-open3e-int-credentials-externalsecret.yaml).
|
||||
- name: OPEN3E_INT_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: mosquitto-open3e-int-credentials
|
||||
key: password
|
||||
- name: EVCC_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Projects the mosquitto `open3e-int` broker credential (open3e-esp32 gateway
|
||||
# on the Vitocal internal CAN) from OpenBao into the Secret the broker
|
||||
# consumes. Rotator: kubernetes/infrastructure/openbao/rotation/mosquitto-open3e-int.yaml.
|
||||
#
|
||||
# creationPolicy is implicitly Owner: deleting this ExternalSecret deletes the
|
||||
# live Secret. Set creationPolicy: Orphan first to detach without destroying.
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: mosquitto-open3e-int-credentials
|
||||
namespace: home-assistant
|
||||
spec:
|
||||
refreshInterval: 10m
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: mosquitto-open3e-int-credentials
|
||||
dataFrom:
|
||||
- extract:
|
||||
key: apps/home-assistant/mosquitto-open3e-int-credentials
|
||||
@@ -28,6 +28,38 @@ spec:
|
||||
requests:
|
||||
storage: 300Gi
|
||||
---
|
||||
# Media root on the EC 2+1 bulk pool; thumbs mount separately below.
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: immich-library-bulk
|
||||
namespace: immich
|
||||
annotations:
|
||||
kustomize.toolkit.fluxcd.io/prune: disabled
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteMany
|
||||
storageClassName: ceph-filesystem-bulk
|
||||
resources:
|
||||
requests:
|
||||
storage: 300Gi
|
||||
---
|
||||
# Thumbnails stay replicated: read affinity serves them from the local OSD.
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: immich-thumbs
|
||||
namespace: immich
|
||||
annotations:
|
||||
kustomize.toolkit.fluxcd.io/prune: disabled
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteMany
|
||||
storageClassName: ceph-filesystem
|
||||
resources:
|
||||
requests:
|
||||
storage: 30Gi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
|
||||
@@ -110,6 +110,8 @@ spec:
|
||||
volumeMounts:
|
||||
- name: library
|
||||
mountPath: /data
|
||||
- name: thumbs
|
||||
mountPath: /data/thumbs
|
||||
- name: immich-config-file
|
||||
mountPath: /config
|
||||
readOnly: true
|
||||
@@ -145,7 +147,10 @@ spec:
|
||||
volumes:
|
||||
- name: library
|
||||
persistentVolumeClaim:
|
||||
claimName: immich-library
|
||||
claimName: immich-library-bulk
|
||||
- name: thumbs
|
||||
persistentVolumeClaim:
|
||||
claimName: immich-thumbs
|
||||
- name: immich-config-file
|
||||
secret:
|
||||
secretName: immich-secrets
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# the entrypoint exit after one pass instead of looping internally —
|
||||
# the CronJob handles scheduling.
|
||||
#
|
||||
# Books land in /audiobooks (the audiobookshelf-books PVC, shared
|
||||
# Books land in /audiobooks (the audiobookshelf-books-bulk PVC, shared
|
||||
# with the audiobookshelf Deployment). Config + DB live on
|
||||
# libation-config (RWO ceph-block, seeded once from
|
||||
# libation-bootstrap secret).
|
||||
@@ -107,6 +107,9 @@ spec:
|
||||
- |
|
||||
trap 'cp /config-internal/AccountsSettings.json /libation-state/AccountsSettings.json' EXIT
|
||||
set -e
|
||||
# Strip the Audible intro/outro; re-liberated books replace their file instead of gaining a " (1)" copy.
|
||||
jq '.StripAudibleBrandAudio = true | .OverwriteExisting = true' /config-internal/Settings.json > /config-internal/Settings.json.tmp
|
||||
mv /config-internal/Settings.json.tmp /config-internal/Settings.json
|
||||
/libation/LibationCli scan
|
||||
/libation/LibationCli liberate
|
||||
env:
|
||||
@@ -135,7 +138,7 @@ spec:
|
||||
claimName: libation-config
|
||||
- name: books
|
||||
persistentVolumeClaim:
|
||||
claimName: audiobookshelf-books
|
||||
claimName: audiobookshelf-books-bulk
|
||||
- name: bootstrap
|
||||
secret:
|
||||
secretName: libation-bootstrap
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# SOPS-encrypted Secret on first boot (sentinel file gate); after that
|
||||
# Libation reads and writes it directly so token rotation persists.
|
||||
#
|
||||
# Books are written to the audiobookshelf-books PVC (already provisioned
|
||||
# Books are written to the audiobookshelf-books-bulk PVC (already provisioned
|
||||
# by the audiobookshelf seed Kustomization, RWX ceph-filesystem). We
|
||||
# share the same namespace so the PVC reference is direct — k8s PVCs
|
||||
# are namespace-scoped.
|
||||
|
||||
@@ -88,6 +88,64 @@ spec:
|
||||
# /ready answers 200 unauthenticated and is in this namespace, so the
|
||||
# same intra-namespace allowance the two above rely on covers it.
|
||||
- http://loki.monitoring.svc.cluster.local:3100/ready
|
||||
# APP-HEALTH probes for the two gated navidrome-namespace apps. Same
|
||||
# rule as the block above (monitoring-vantage-point-rule), but these
|
||||
# are the first targets here that leave the monitoring namespace.
|
||||
#
|
||||
# Both sit entirely behind an Authentik outpost — navidrome.renner.uno
|
||||
# on ak-outpost-navidrome-gate (navidrome/ingress.yaml:21),
|
||||
# lidarr.renner.uno on ak-outpost-lidarr-gate
|
||||
# (navidrome/lidarr.yaml:218) — so an EXTERNAL probe of either name
|
||||
# measures the gate, not the app, and stays green while the app behind
|
||||
# a live gate is dead. That external signal is still wanted, and REN-2
|
||||
# adds it from UptimeRobot; these are the other half of the pair.
|
||||
#
|
||||
# NO CiliumNetworkPolicy change is needed, unlike what a reading of
|
||||
# cnp-cross-namespace-allow-apps-gatus-to-monitored-services suggests.
|
||||
# That gotcha is about `apps`, where GATUS runs; the influxdb
|
||||
# allow-list it describes already contained `monitoring`. The
|
||||
# navidrome baseline CNP likewise already admits the whole monitoring
|
||||
# namespace on every port
|
||||
# (infrastructure/network-policies/navidrome-ns.yaml:25-27), which is
|
||||
# where blackbox-exporter runs. Nothing in the navidrome namespace is
|
||||
# edited by this change.
|
||||
#
|
||||
# /ping on both: unauthenticated, and already the pods' own kubelet
|
||||
# probe path (navidrome/deployment.yaml:61-74,
|
||||
# navidrome/lidarr.yaml:114-127). For lidarr that it is ungated is
|
||||
# proven independently of the gate — lidarr.yaml:29 curls /ping in a
|
||||
# loop BEFORE it has read the API key out of config.xml.
|
||||
- http://navidrome.navidrome.svc.cluster.local:80/ping
|
||||
- http://lidarr.navidrome.svc.cluster.local:80/ping
|
||||
# APP-HEALTH probe for paperless. Same rule as the block above
|
||||
# (monitoring-vantage-point-rule); the blind spot it closes is the one
|
||||
# gatus already documents at apps/gatus/configmap.yaml:176-185 —
|
||||
# through the paperless-gate outpost EVERY path, /api/ included,
|
||||
# answers 302 to an unauthenticated caller, so no external probe of
|
||||
# paperless.renner.uno can tell a live app from a live gate. That
|
||||
# external signal is still wanted, and REN-2 adds it from UptimeRobot;
|
||||
# this is the other half of the pair.
|
||||
#
|
||||
# That gatus comment says the in-cluster probe "needs a
|
||||
# CiliumNetworkPolicy first". It does not, for THIS prober — the
|
||||
# sentence was written for gatus, which runs in `apps`.
|
||||
# paperless-ns.yaml:24-26 already admits the whole monitoring
|
||||
# namespace, which is where blackbox-exporter runs, and Cilium unions
|
||||
# the allow rules of every CNP selecting an endpoint (the mechanism
|
||||
# apps/paperless/network-policy.yaml:8-12 relies on). Nothing in the
|
||||
# paperless namespace is edited by this change.
|
||||
#
|
||||
# /accounts/login/ because paperless-ngx has no unauthenticated health
|
||||
# endpoint at all. It is the app's own established "the webserver
|
||||
# answers" signal — the same path its startup, liveness and readiness
|
||||
# probes use (paperless.yaml:252-272) — and it renders a Django view
|
||||
# rather than a static file. It does NOT cover the consumer/celery
|
||||
# workers: see process-liveness-vs-protocol-liveness.
|
||||
#
|
||||
# The cluster-local Host header is accepted only because
|
||||
# PAPERLESS_ALLOWED_HOSTS is "*" (paperless.yaml:27). Narrow that and
|
||||
# Django answers 400 here and this probe goes permanently red.
|
||||
- http://paperless.paperless.svc.cluster.local:80/accounts/login/
|
||||
# WAN-path probes of the public edge BY PROXY IP. The name-based
|
||||
# home.renner.uno probe above resolves in-cluster (CoreDNS/k8s_gateway)
|
||||
# and never leaves the LAN — the vantage-point blind spot that let the
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: navidrome-config
|
||||
namespace: navidrome
|
||||
data:
|
||||
TZ: "Europe/Berlin"
|
||||
ND_LOGLEVEL: "info"
|
||||
ND_SCANNER_SCHEDULE: "0 * * * *"
|
||||
ND_ENABLEINSIGHTSCOLLECTOR: "false"
|
||||
# Identity from the navidrome-gate outpost; the CNP admits only the outpost
|
||||
# and Traefik, and Traefik strips this header on the ungated routes.
|
||||
ND_EXTAUTH_USERHEADER: "X-Authentik-Username"
|
||||
ND_EXTAUTH_TRUSTEDSOURCES: "fd10:244::/48,10.244.0.0/16"
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: navidrome
|
||||
namespace: navidrome
|
||||
labels:
|
||||
app.kubernetes.io/name: navidrome
|
||||
annotations:
|
||||
configmap.reloader.stakater.com/reload: navidrome-config
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: navidrome
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: navidrome
|
||||
spec:
|
||||
enableServiceLinks: false
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
fsGroup: 1000
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: navidrome
|
||||
image: deluan/navidrome:0.64.2 # {"$imagepolicy": "flux-system:navidrome"}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 4533
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: navidrome-config
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
- name: music
|
||||
mountPath: /music
|
||||
readOnly: true
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /ping
|
||||
port: http
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 10
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /ping
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
memory: 1Gi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: navidrome-data
|
||||
- name: music
|
||||
persistentVolumeClaim:
|
||||
claimName: navidrome-music-bulk
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: navidrome
|
||||
namespace: navidrome
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: navidrome
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: http
|
||||
@@ -0,0 +1,13 @@
|
||||
# ExternalName mirror so the navidrome-namespace Ingress can backend to the
|
||||
# navidrome-gate outpost in the authentik namespace.
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ak-outpost-navidrome-gate
|
||||
namespace: navidrome
|
||||
spec:
|
||||
type: ExternalName
|
||||
externalName: ak-outpost-navidrome-gate.authentik.svc.cluster.local
|
||||
ports:
|
||||
- port: 9000
|
||||
targetPort: 9000
|
||||
@@ -0,0 +1,69 @@
|
||||
# Web UI behind the navidrome-gate outpost.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: navidrome
|
||||
namespace: navidrome
|
||||
labels:
|
||||
dns.renner.uno/public: "true"
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: navidrome.renner.uno
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: ak-outpost-navidrome-gate
|
||||
port:
|
||||
number: 9000
|
||||
tls:
|
||||
- secretName: navidrome-tls
|
||||
hosts:
|
||||
- navidrome.renner.uno
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: navidrome-strip-identity
|
||||
namespace: navidrome
|
||||
spec:
|
||||
headers:
|
||||
customRequestHeaders:
|
||||
X-Authentik-Username: ""
|
||||
---
|
||||
# Subsonic API and public shares bypass the gate; clients authenticate with
|
||||
# their Navidrome password.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: navidrome-subsonic
|
||||
namespace: navidrome
|
||||
labels:
|
||||
dns.renner.uno/public: "true"
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.middlewares: navidrome-navidrome-strip-identity@kubernetescrd
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: navidrome.renner.uno
|
||||
http:
|
||||
paths:
|
||||
- path: /rest
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: navidrome
|
||||
port:
|
||||
number: 80
|
||||
- path: /share
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: navidrome
|
||||
port:
|
||||
number: 80
|
||||
@@ -0,0 +1,8 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- pvcs.yaml
|
||||
- deployment.yaml
|
||||
- gate-service.yaml
|
||||
- ingress.yaml
|
||||
- lidarr.yaml
|
||||
@@ -0,0 +1,224 @@
|
||||
# Lidarr as a read-mostly "what is missing" view over the Navidrome library.
|
||||
# No download client or indexer is configured.
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: lidarr-config
|
||||
namespace: navidrome
|
||||
annotations:
|
||||
kustomize.toolkit.fluxcd.io/prune: disabled
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: ceph-block
|
||||
resources:
|
||||
requests:
|
||||
storage: 5Gi
|
||||
---
|
||||
# Applied on every pod start, before the root folder exists: no renames, no tag
|
||||
# writes, no deletes. Adding /music as root folder starts Lidarr's library import.
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: lidarr-reconcile
|
||||
namespace: navidrome
|
||||
data:
|
||||
reconcile.sh: |
|
||||
set -eu
|
||||
API=http://localhost:8686/api/v1
|
||||
until curl -sf http://localhost:8686/ping >/dev/null; do sleep 5; done
|
||||
KEY=$(sed -n 's:.*<ApiKey>\(.*\)</ApiKey>.*:\1:p' /config/config.xml)
|
||||
if [ -z "$KEY" ]; then
|
||||
echo "FATAL: no ApiKey in /config/config.xml" >&2
|
||||
exit 1
|
||||
fi
|
||||
H="X-Api-Key: $KEY"
|
||||
|
||||
put() {
|
||||
cur=$(curl -sf -H "$H" "$API/$1")
|
||||
id=$(printf '%s' "$cur" | jq -r '.id')
|
||||
printf '%s' "$cur" | jq -c "$2" \
|
||||
| curl -sf -X PUT -H "$H" -H 'Content-Type: application/json' -d @- "$API/$1/$id" >/dev/null
|
||||
echo "reconciled $1"
|
||||
}
|
||||
|
||||
put config/naming '.renameTracks = false'
|
||||
put config/metadataprovider '.writeAudioTags = "no" | .scrubAudioTags = false | .embedCoverArt = false'
|
||||
put config/mediamanagement '.deleteEmptyFolders = false | .createEmptyArtistFolders = false
|
||||
| .recycleBin = "" | .setPermissionsLinux = false | .importExtraFiles = false
|
||||
| .skipFreeSpaceCheckWhenImporting = true'
|
||||
|
||||
roots=$(curl -sf -H "$H" "$API/rootfolder" | jq '[.[] | select(.path == "/music")] | length')
|
||||
if [ "$roots" = "0" ]; then
|
||||
qp=$(curl -sf -H "$H" "$API/qualityprofile" | jq '.[0].id')
|
||||
mp=$(curl -sf -H "$H" "$API/metadataprofile" | jq '([.[] | select(.name == "Standard")][0].id) // .[0].id')
|
||||
jq -nc --argjson qp "$qp" --argjson mp "$mp" '{
|
||||
name: "Music", path: "/music",
|
||||
defaultQualityProfileId: $qp, defaultMetadataProfileId: $mp,
|
||||
defaultMonitorOption: "all", defaultNewItemMonitorOption: "all",
|
||||
defaultTags: []
|
||||
}' \
|
||||
| curl -sf -X POST -H "$H" -H 'Content-Type: application/json' -d @- "$API/rootfolder" >/dev/null
|
||||
echo "added root folder /music"
|
||||
fi
|
||||
|
||||
echo "reconcile done"
|
||||
while true; do sleep 3600; done
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: lidarr
|
||||
namespace: navidrome
|
||||
labels:
|
||||
app.kubernetes.io/name: lidarr
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: lidarr
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: lidarr
|
||||
spec:
|
||||
enableServiceLinks: false
|
||||
securityContext:
|
||||
fsGroup: 1000
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: lidarr
|
||||
image: lscr.io/linuxserver/lidarr:3.1.0.4875-ls42 # {"$imagepolicy": "flux-system:lidarr"}
|
||||
env:
|
||||
- name: PUID
|
||||
value: "1000"
|
||||
- name: PGID
|
||||
value: "1000"
|
||||
- name: TZ
|
||||
value: Europe/Berlin
|
||||
# Identity is enforced by the lidarr-gate outpost.
|
||||
- name: LIDARR__AUTH__METHOD
|
||||
value: External
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8686
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /config
|
||||
- name: music
|
||||
mountPath: /music
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /ping
|
||||
port: http
|
||||
initialDelaySeconds: 60
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 10
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /ping
|
||||
port: http
|
||||
initialDelaySeconds: 20
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
memory: 4Gi
|
||||
securityContext:
|
||||
runAsNonRoot: false
|
||||
allowPrivilegeEscalation: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
add: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID"]
|
||||
- name: reconcile
|
||||
image: lscr.io/linuxserver/lidarr:3.1.0.4875-ls42 # {"$imagepolicy": "flux-system:lidarr"}
|
||||
command: ["sh", "/scripts/reconcile.sh"]
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /config
|
||||
readOnly: true
|
||||
- name: reconcile
|
||||
mountPath: /scripts
|
||||
readOnly: true
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 16Mi
|
||||
limits:
|
||||
memory: 64Mi
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumes:
|
||||
- name: config
|
||||
persistentVolumeClaim:
|
||||
claimName: lidarr-config
|
||||
- name: music
|
||||
persistentVolumeClaim:
|
||||
claimName: navidrome-music-bulk
|
||||
- name: reconcile
|
||||
configMap:
|
||||
name: lidarr-reconcile
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: lidarr
|
||||
namespace: navidrome
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: lidarr
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: http
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ak-outpost-lidarr-gate
|
||||
namespace: navidrome
|
||||
spec:
|
||||
type: ExternalName
|
||||
externalName: ak-outpost-lidarr-gate.authentik.svc.cluster.local
|
||||
ports:
|
||||
- port: 9000
|
||||
targetPort: 9000
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: lidarr
|
||||
namespace: navidrome
|
||||
labels:
|
||||
dns.renner.uno/public: "true"
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: lidarr.renner.uno
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: ak-outpost-lidarr-gate
|
||||
port:
|
||||
number: 9000
|
||||
tls:
|
||||
- secretName: lidarr-tls
|
||||
hosts:
|
||||
- lidarr.renner.uno
|
||||
@@ -0,0 +1,31 @@
|
||||
# SQLite database + artwork/transcode cache; single writer.
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: navidrome-data
|
||||
namespace: navidrome
|
||||
annotations:
|
||||
kustomize.toolkit.fluxcd.io/prune: disabled
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: ceph-block
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
---
|
||||
# Music library on the EC bulk pool.
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: navidrome-music-bulk
|
||||
namespace: navidrome
|
||||
annotations:
|
||||
kustomize.toolkit.fluxcd.io/prune: disabled
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteMany
|
||||
storageClassName: ceph-filesystem-bulk
|
||||
resources:
|
||||
requests:
|
||||
storage: 300Gi
|
||||
@@ -0,0 +1,43 @@
|
||||
apiVersion: postgresql.cnpg.io/v1
|
||||
kind: Cluster
|
||||
metadata:
|
||||
name: pclp-pg
|
||||
namespace: paperclip
|
||||
annotations:
|
||||
kustomize.toolkit.fluxcd.io/prune: disabled # data guard
|
||||
spec:
|
||||
instances: 2
|
||||
smartShutdownTimeout: 10
|
||||
stopDelay: 60
|
||||
# PG17: upstream's external Postgres target (docker/docker-compose.yml).
|
||||
imageName: ghcr.io/cloudnative-pg/postgresql:17
|
||||
storage:
|
||||
storageClass: ceph-block
|
||||
size: 10Gi
|
||||
plugins:
|
||||
- name: barman-cloud.cloudnative-pg.io
|
||||
isWALArchiver: true
|
||||
parameters:
|
||||
barmanObjectName: pclp-pg-store
|
||||
bootstrap:
|
||||
initdb:
|
||||
database: paperclip
|
||||
owner: paperclip
|
||||
postgresql:
|
||||
parameters:
|
||||
shared_buffers: "256MB"
|
||||
max_connections: "200"
|
||||
checkpoint_timeout: "15min"
|
||||
max_wal_size: "2GB"
|
||||
wal_compression: "zstd"
|
||||
archive_timeout: "30min"
|
||||
synchronous:
|
||||
method: any
|
||||
number: 1
|
||||
dataDurability: preferred
|
||||
resources:
|
||||
requests:
|
||||
cpu: 200m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
memory: 1Gi
|
||||
@@ -0,0 +1,15 @@
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: paperclip-secrets
|
||||
namespace: paperclip
|
||||
spec:
|
||||
refreshInterval: 10m
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: paperclip-secrets
|
||||
dataFrom:
|
||||
- extract:
|
||||
key: apps/paperclip
|
||||
@@ -0,0 +1,12 @@
|
||||
# ExternalName mirror so the Ingress can backend the paperclip-gate outpost in the authentik namespace.
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ak-outpost-paperclip-gate
|
||||
namespace: paperclip
|
||||
spec:
|
||||
type: ExternalName
|
||||
externalName: ak-outpost-paperclip-gate.authentik.svc.cluster.local
|
||||
ports:
|
||||
- port: 9000
|
||||
targetPort: 9000
|
||||
@@ -0,0 +1,17 @@
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: hetzner-s3-creds
|
||||
namespace: paperclip
|
||||
annotations:
|
||||
kustomize.toolkit.fluxcd.io/prune: disabled # barman S3 creds
|
||||
spec:
|
||||
refreshInterval: 1h
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: hetzner-s3-creds
|
||||
dataFrom:
|
||||
- extract:
|
||||
key: infrastructure/hetzner-s3
|
||||
@@ -0,0 +1,25 @@
|
||||
# LAN-only (no dns.renner.uno/public label); backends the gate outpost, never the Service.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: paperclip
|
||||
namespace: paperclip
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts:
|
||||
- paperclip.renner.uno
|
||||
secretName: paperclip-tls
|
||||
rules:
|
||||
- host: paperclip.renner.uno
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: ak-outpost-paperclip-gate
|
||||
port:
|
||||
number: 9000
|
||||
@@ -0,0 +1,13 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- externalsecret.yaml
|
||||
- hetzner-creds-externalsecret.yaml
|
||||
- cluster.yaml
|
||||
- objectstore.yaml
|
||||
- scheduled-backup.yaml
|
||||
- pvc.yaml
|
||||
- paperclip.yaml
|
||||
- gate-service.yaml
|
||||
- ingress.yaml
|
||||
- network-policy.yaml
|
||||
@@ -0,0 +1,78 @@
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: paperclip-gate-ingress
|
||||
namespace: paperclip
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: paperclip
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": authentik
|
||||
goauthentik.io/outpost-name: paperclip-gate
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "3100"
|
||||
protocol: TCP
|
||||
---
|
||||
# Covers the server and every agent it spawns (same pod).
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: paperclip-egress
|
||||
namespace: paperclip
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: paperclip
|
||||
egress:
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": paperclip
|
||||
cnpg.io/cluster: pclp-pg
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "5432"
|
||||
protocol: TCP
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": kube-system
|
||||
k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
# Gitea, Woodpecker, HA, Loki via their *.renner.uno names.
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": ingress
|
||||
app.kubernetes.io/name: traefik
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8443"
|
||||
protocol: TCP
|
||||
# Notes wiki API, authenticated by the paperclip API key.
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": ai
|
||||
app.kubernetes.io/name: wiki
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8080"
|
||||
protocol: TCP
|
||||
- toEntities:
|
||||
- kube-apiserver
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "6443"
|
||||
protocol: TCP
|
||||
- toEntities:
|
||||
- world
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "443"
|
||||
protocol: TCP
|
||||
@@ -0,0 +1,26 @@
|
||||
apiVersion: barmancloud.cnpg.io/v1
|
||||
kind: ObjectStore
|
||||
metadata:
|
||||
name: pclp-pg-store
|
||||
namespace: paperclip
|
||||
annotations:
|
||||
kustomize.toolkit.fluxcd.io/prune: disabled # backup target
|
||||
spec:
|
||||
configuration:
|
||||
destinationPath: s3://renner-uno-k8s-cnpg/pclp-pg
|
||||
endpointURL: https://fsn1.your-objectstorage.com
|
||||
s3Credentials:
|
||||
accessKeyId:
|
||||
name: hetzner-s3-creds
|
||||
key: ACCESS_KEY_ID
|
||||
secretAccessKey:
|
||||
name: hetzner-s3-creds
|
||||
key: ACCESS_SECRET_KEY
|
||||
region:
|
||||
name: hetzner-s3-creds
|
||||
key: REGION
|
||||
wal:
|
||||
compression: gzip
|
||||
data:
|
||||
compression: gzip
|
||||
retentionPolicy: "30d"
|
||||
@@ -0,0 +1,123 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: paperclip
|
||||
namespace: paperclip
|
||||
labels:
|
||||
app.kubernetes.io/name: paperclip
|
||||
annotations:
|
||||
secret.reloader.stakater.com/reload: paperclip-secrets
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: paperclip
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: paperclip
|
||||
spec:
|
||||
enableServiceLinks: false
|
||||
# Every agent shares this filesystem; bots bring their own cluster credentials.
|
||||
automountServiceAccountToken: false
|
||||
securityContext:
|
||||
fsGroup: 1000
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: paperclip
|
||||
image: ghcr.io/paperclipai/paperclip:nightly@sha256:9222efbf11863160669e13d5559e6a1be881886dd1758650b74c3993dc3ef2a6 # {"$imagepolicy": "flux-system:paperclip"}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3100
|
||||
env:
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: pclp-pg-app
|
||||
key: uri
|
||||
- name: HOST
|
||||
value: "0.0.0.0"
|
||||
- name: PORT
|
||||
value: "3100"
|
||||
- name: PAPERCLIP_DEPLOYMENT_MODE
|
||||
value: authenticated
|
||||
- name: PAPERCLIP_DEPLOYMENT_EXPOSURE
|
||||
value: private
|
||||
- name: PAPERCLIP_PUBLIC_URL
|
||||
value: https://paperclip.renner.uno
|
||||
# Agents reach the API via the Service, not through the Authentik gate.
|
||||
- name: PAPERCLIP_API_URL
|
||||
value: http://paperclip.paperclip.svc.cluster.local:3100
|
||||
- name: PAPERCLIP_ALLOWED_HOSTNAMES
|
||||
value: paperclip.paperclip.svc.cluster.local
|
||||
- name: PAPERCLIP_AUTH_DISABLE_SIGN_UP
|
||||
value: "true"
|
||||
- name: PAPERCLIP_TELEMETRY_DISABLED
|
||||
value: "1"
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: paperclip-secrets
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /paperclip
|
||||
# The private-hostname guard 403s any Host but loopback and the public URL; the kubelet sends the pod IP.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: http
|
||||
httpHeaders:
|
||||
- name: Host
|
||||
value: localhost
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 60
|
||||
# /api/health runs SELECT 1; liveness stays DB-independent so a Postgres outage doesn't restart the server.
|
||||
livenessProbe:
|
||||
tcpSocket:
|
||||
port: http
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 10
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: http
|
||||
httpHeaders:
|
||||
- name: Host
|
||||
value: localhost
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
# Every concurrent agent (Claude Code + MCP helpers) runs inside this container.
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 2Gi
|
||||
limits:
|
||||
memory: 32Gi
|
||||
# Entrypoint remaps the node user and chowns /paperclip as root, then drops to it via gosu.
|
||||
securityContext:
|
||||
runAsNonRoot: false
|
||||
allowPrivilegeEscalation: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
add: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID"]
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: paperclip-data
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: paperclip
|
||||
namespace: paperclip
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: paperclip
|
||||
ports:
|
||||
- name: http
|
||||
port: 3100
|
||||
targetPort: http
|
||||
@@ -0,0 +1,13 @@
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: paperclip-data
|
||||
namespace: paperclip
|
||||
annotations:
|
||||
kustomize.toolkit.fluxcd.io/prune: disabled # data guard
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
storageClassName: ceph-block
|
||||
resources:
|
||||
requests:
|
||||
storage: 20Gi
|
||||
@@ -0,0 +1,16 @@
|
||||
apiVersion: postgresql.cnpg.io/v1
|
||||
kind: ScheduledBackup
|
||||
metadata:
|
||||
name: pclp-pg-daily
|
||||
namespace: paperclip
|
||||
annotations:
|
||||
kustomize.toolkit.fluxcd.io/prune: disabled # backup schedule
|
||||
spec:
|
||||
schedule: "0 45 2 * * *"
|
||||
immediate: true
|
||||
backupOwnerReference: self
|
||||
cluster:
|
||||
name: pclp-pg
|
||||
method: plugin
|
||||
pluginConfiguration:
|
||||
name: barman-cloud.cloudnative-pg.io
|
||||
@@ -1,4 +1,3 @@
|
||||
---
|
||||
# Index snapshots per embedding model and the document catalog. A lost volume
|
||||
# costs one full sync from Gitea (~10 min).
|
||||
apiVersion: v1
|
||||
@@ -23,6 +22,9 @@ kind: Deployment
|
||||
metadata:
|
||||
name: wiki
|
||||
namespace: ai
|
||||
annotations:
|
||||
# Keys, tokens and the Anthropic key arrive via envFrom; a rotated Secret rolls the pod.
|
||||
secret.reloader.stakater.com/reload: wiki-gitea,wiki-api-keys,wiki-typesafe,wiki-anthropic
|
||||
labels:
|
||||
app.kubernetes.io/name: wiki
|
||||
spec:
|
||||
@@ -47,8 +49,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: wiki
|
||||
# build.<unix-ts> tags from the Wiki repo's release pipeline (Wiki PR #5).
|
||||
image: gitea.renner.uno/konairius/wiki:build.1790257574
|
||||
image: gitea.renner.uno/konairius/wiki:build.1790322994 # {"$imagepolicy": "flux-system:wiki"}
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
@@ -79,6 +80,9 @@ spec:
|
||||
name: wiki-api-keys
|
||||
- secretRef:
|
||||
name: wiki-typesafe
|
||||
- secretRef:
|
||||
name: wiki-anthropic
|
||||
optional: true
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
# Gitea read token and webhook secret, shared with the Python wiki-index until
|
||||
# that is retired. Secret keys are the configuration paths the host reads.
|
||||
# Gitea token (claude-bot, write:repository: the service creates raw/ files for
|
||||
# deposited facts) and the notes webhook secret. Secret keys are the
|
||||
# configuration paths the host reads.
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
@@ -16,7 +17,7 @@ spec:
|
||||
data:
|
||||
- secretKey: Wiki__NoteStore__Gitea__Token
|
||||
remoteRef:
|
||||
key: apps/ai/wiki-index
|
||||
key: apps/ai/wiki-service
|
||||
property: gitea_token
|
||||
- secretKey: Wiki__Webhook__Secret
|
||||
remoteRef:
|
||||
@@ -41,6 +42,10 @@ spec:
|
||||
remoteRef:
|
||||
key: apps/ai/wiki-api-keys
|
||||
property: workstation
|
||||
- secretKey: Auth__ApiKeys__paperclip
|
||||
remoteRef:
|
||||
key: apps/ai/wiki-api-keys
|
||||
property: paperclip
|
||||
---
|
||||
# TypeSafe System One key for the judge adapter (Wiki:Judges:Models:typesafe).
|
||||
apiVersion: external-secrets.io/v1
|
||||
@@ -61,6 +66,26 @@ spec:
|
||||
key: apps/ai/wiki-typesafe
|
||||
property: api_key
|
||||
---
|
||||
# Answer model: Wiki:AnswerModels:Models:claude-sonnet. The pod starts without it (optional
|
||||
# secretRef); the questions endpoint answers 503 until the key is placed.
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: wiki-anthropic
|
||||
namespace: ai
|
||||
spec:
|
||||
refreshInterval: 1h
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: wiki-anthropic
|
||||
data:
|
||||
- secretKey: Wiki__AnswerModels__Models__claude-sonnet__ApiKey
|
||||
remoteRef:
|
||||
key: apps/ai/wiki-anthropic
|
||||
property: api_key
|
||||
---
|
||||
# BasicAuth users for the machine door; same OpenBao key as the Python service used.
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
|
||||
@@ -1955,6 +1955,7 @@ if phase_runs wait-apps; then
|
||||
wait_kustomization paperless "Paperless (incl. pprl-pg CNPG + data PVCs)" 600
|
||||
wait_kustomization home-assistant "Home Assistant (HAOS backup → native restore)" 180
|
||||
wait_kustomization audiobookshelf "Audiobookshelf (incl. library/metadata PVCs)" 900
|
||||
wait_kustomization navidrome "Navidrome (incl. data + music library PVCs)" 600
|
||||
wait_kustomization immich "Immich (incl. im-pg CNPG + library PVC)" 1800
|
||||
|
||||
# Wait for cert-manager to finish issuing every Certificate. Kustomization
|
||||
|
||||
@@ -5,7 +5,7 @@ metadata:
|
||||
namespace: flux-system
|
||||
spec:
|
||||
# Headless Libation CronJob that downloads new Audible books into
|
||||
# the audiobookshelf-books PVC. Lives in the audiobookshelf namespace
|
||||
# the audiobookshelf-books-bulk PVC. Lives in the audiobookshelf namespace
|
||||
# because k8s PVCs are namespace-scoped and we share the books volume
|
||||
# with the audiobookshelf Deployment. Depends on audiobookshelf so
|
||||
# the books PVC exists before the libation-bootstrap secret/CronJob
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: navidrome
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 10m
|
||||
retryInterval: 1m
|
||||
timeout: 15m
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: flux-system
|
||||
path: ./kubernetes/apps/navidrome
|
||||
prune: true
|
||||
wait: true
|
||||
dependsOn:
|
||||
- name: infrastructure
|
||||
- name: infrastructure-crds
|
||||
- name: infrastructure-ceph-cluster
|
||||
@@ -0,0 +1,25 @@
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: paperclip
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 10m
|
||||
retryInterval: 1m
|
||||
timeout: 20m
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: flux-system
|
||||
path: ./kubernetes/apps/paperclip
|
||||
prune: true
|
||||
wait: true
|
||||
dependsOn:
|
||||
- name: infrastructure
|
||||
- name: infrastructure-crds
|
||||
- name: infrastructure-ceph-cluster
|
||||
- name: infrastructure-cnpg-barman
|
||||
healthCheckExprs:
|
||||
- apiVersion: postgresql.cnpg.io/v1
|
||||
kind: Cluster
|
||||
failed: "has(status.phase) && status.phase.startsWith('Failed')"
|
||||
current: "has(status.phase) && (status.phase == 'Cluster in healthy state' || status.readyInstances > 0)"
|
||||
@@ -9,11 +9,14 @@ resources:
|
||||
- gotenberg.yaml
|
||||
- home-assistant.yaml
|
||||
- immich.yaml
|
||||
- lidarr.yaml
|
||||
- matter-server.yaml
|
||||
- mmwave-configurator.yaml
|
||||
- music-assistant.yaml
|
||||
- navidrome.yaml
|
||||
- netbird-ingress.yaml
|
||||
- paperless-ngx.yaml
|
||||
- paperclip.yaml
|
||||
- protonmail-bridge.yaml
|
||||
- mosquitto.yaml
|
||||
- redis.yaml
|
||||
@@ -24,5 +27,6 @@ resources:
|
||||
- valkey.yaml
|
||||
- velero-plugin-for-aws.yaml
|
||||
- vsftpd.yaml
|
||||
- wiki.yaml
|
||||
- woodpecker.yaml
|
||||
- wyoming.yaml
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImageRepository
|
||||
metadata:
|
||||
name: lidarr
|
||||
namespace: flux-system
|
||||
spec:
|
||||
image: lscr.io/linuxserver/lidarr
|
||||
interval: 1h
|
||||
---
|
||||
# Tags are <major>.<minor>.<patch>.<build>-ls<n>; the build number is monotonic.
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImagePolicy
|
||||
metadata:
|
||||
name: lidarr
|
||||
namespace: flux-system
|
||||
spec:
|
||||
imageRepositoryRef:
|
||||
name: lidarr
|
||||
filterTags:
|
||||
pattern: '^\d+\.\d+\.\d+\.(?P<build>\d+)-ls\d+$'
|
||||
extract: '$build'
|
||||
policy:
|
||||
numerical:
|
||||
order: asc
|
||||
@@ -0,0 +1,20 @@
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImageRepository
|
||||
metadata:
|
||||
name: navidrome
|
||||
namespace: flux-system
|
||||
spec:
|
||||
image: deluan/navidrome
|
||||
interval: 1h
|
||||
---
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImagePolicy
|
||||
metadata:
|
||||
name: navidrome
|
||||
namespace: flux-system
|
||||
spec:
|
||||
imageRepositoryRef:
|
||||
name: navidrome
|
||||
policy:
|
||||
semver:
|
||||
range: ">=0.64.0"
|
||||
@@ -0,0 +1,25 @@
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImageRepository
|
||||
metadata:
|
||||
name: paperclip
|
||||
namespace: flux-system
|
||||
spec:
|
||||
image: ghcr.io/paperclipai/paperclip
|
||||
interval: 1h
|
||||
---
|
||||
# Upstream publishes no semver tags; track the nightly tag by digest.
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImagePolicy
|
||||
metadata:
|
||||
name: paperclip
|
||||
namespace: flux-system
|
||||
spec:
|
||||
imageRepositoryRef:
|
||||
name: paperclip
|
||||
digestReflectionPolicy: Always
|
||||
interval: 1h
|
||||
filterTags:
|
||||
pattern: '^nightly$'
|
||||
policy:
|
||||
alphabetical:
|
||||
order: asc
|
||||
@@ -0,0 +1,24 @@
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImageRepository
|
||||
metadata:
|
||||
name: wiki
|
||||
namespace: flux-system
|
||||
spec:
|
||||
image: gitea.renner.uno/konairius/wiki
|
||||
interval: 10m
|
||||
---
|
||||
# The Wiki release pipeline tags every main build `build.<unix-ts>`; latest push wins.
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImagePolicy
|
||||
metadata:
|
||||
name: wiki
|
||||
namespace: flux-system
|
||||
spec:
|
||||
imageRepositoryRef:
|
||||
name: wiki
|
||||
filterTags:
|
||||
pattern: '^build\.(?P<ts>\d+)$'
|
||||
extract: '$ts'
|
||||
policy:
|
||||
numerical:
|
||||
order: asc
|
||||
@@ -78,6 +78,11 @@ metadata:
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: navidrome
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: immich
|
||||
---
|
||||
@@ -234,3 +239,8 @@ metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: woodpecker
|
||||
pod-security.kubernetes.io/enforce: privileged
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: paperclip
|
||||
|
||||
@@ -150,8 +150,8 @@ spec:
|
||||
protocol: TCP
|
||||
---
|
||||
# wiki: the wiki-gate outpost (humans, after Authentik), Traefik (the machine
|
||||
# door wiki-index.renner.uno, BasicAuth with X-Authentik-Username stripped) and
|
||||
# Gitea (push webhook) may reach it. Gitea and wiki-embed are reached through
|
||||
# door wiki-index.renner.uno, BasicAuth with X-Authentik-Username stripped),
|
||||
# Gitea (push webhook) and Paperclip agents (API key) may reach it. Gitea and wiki-embed are reached through
|
||||
# the namespace egress above; no OpenBao access.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
@@ -173,6 +173,9 @@ spec:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": gitea
|
||||
app.kubernetes.io/name: gitea
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": paperclip
|
||||
app.kubernetes.io/name: paperclip
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8080"
|
||||
|
||||
@@ -6,6 +6,7 @@ resources:
|
||||
- ingress-isolation.yaml
|
||||
- authentik-ns.yaml
|
||||
- paperless-ns.yaml
|
||||
- paperclip-ns.yaml
|
||||
- audiobookshelf-ns.yaml
|
||||
- home-assistant-ns.yaml
|
||||
- spegel-ns.yaml
|
||||
@@ -27,4 +28,5 @@ resources:
|
||||
- trmnl-ns.yaml
|
||||
- wordpress-ns.yaml
|
||||
- ai-ns.yaml
|
||||
- navidrome-ns.yaml
|
||||
- allow-cilium-health.yaml
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: default-deny-ingress
|
||||
namespace: navidrome
|
||||
spec:
|
||||
endpointSelector: {}
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": navidrome
|
||||
# Traefik: the ungated /rest and /share routes (identity header stripped).
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": ingress
|
||||
app.kubernetes.io/name: traefik
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": authentik
|
||||
goauthentik.io/outpost-name: navidrome-gate
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": authentik
|
||||
goauthentik.io/outpost-name: lidarr-gate
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": monitoring
|
||||
- fromEntities:
|
||||
- host
|
||||
- remote-node
|
||||
# ICMP — see home-assistant-ns.yaml.
|
||||
- fromEndpoints:
|
||||
- matchExpressions:
|
||||
- key: io.kubernetes.pod.namespace
|
||||
operator: Exists
|
||||
icmps:
|
||||
- fields:
|
||||
- { type: 0, family: IPv4 }
|
||||
- { type: 3, family: IPv4 }
|
||||
- { type: 8, family: IPv4 }
|
||||
- { type: 11, family: IPv4 }
|
||||
- { type: 12, family: IPv4 }
|
||||
- { type: 1, family: IPv6 }
|
||||
- { type: 2, family: IPv6 }
|
||||
- { type: 3, family: IPv6 }
|
||||
- { type: 4, family: IPv6 }
|
||||
- { type: 128, family: IPv6 }
|
||||
- { type: 129, family: IPv6 }
|
||||
- { type: 133, family: IPv6 }
|
||||
- { type: 134, family: IPv6 }
|
||||
- { type: 135, family: IPv6 }
|
||||
- { type: 136, family: IPv6 }
|
||||
- { type: 143, family: IPv6 }
|
||||
egress:
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": navidrome
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": kube-system
|
||||
k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
# Metadata/artwork agents (Last.fm, Deezer, ListenBrainz).
|
||||
- toEntities:
|
||||
- world
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "443"
|
||||
protocol: TCP
|
||||
- toEndpoints:
|
||||
- matchExpressions:
|
||||
- key: io.kubernetes.pod.namespace
|
||||
operator: Exists
|
||||
icmps:
|
||||
- fields:
|
||||
- { type: 0, family: IPv4 }
|
||||
- { type: 3, family: IPv4 }
|
||||
- { type: 8, family: IPv4 }
|
||||
- { type: 11, family: IPv4 }
|
||||
- { type: 12, family: IPv4 }
|
||||
- { type: 1, family: IPv6 }
|
||||
- { type: 2, family: IPv6 }
|
||||
- { type: 3, family: IPv6 }
|
||||
- { type: 4, family: IPv6 }
|
||||
- { type: 128, family: IPv6 }
|
||||
- { type: 129, family: IPv6 }
|
||||
- { type: 133, family: IPv6 }
|
||||
- { type: 134, family: IPv6 }
|
||||
- { type: 135, family: IPv6 }
|
||||
- { type: 136, family: IPv6 }
|
||||
- { type: 143, family: IPv6 }
|
||||
@@ -0,0 +1,97 @@
|
||||
# Traefik is not admitted: the only entry is the paperclip-gate outpost (apps/paperclip/network-policy.yaml).
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: default-deny-ingress
|
||||
namespace: paperclip
|
||||
spec:
|
||||
endpointSelector: {}
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": paperclip
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": database
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": monitoring
|
||||
- fromEntities:
|
||||
- host
|
||||
- remote-node
|
||||
- fromEndpoints:
|
||||
- matchExpressions:
|
||||
- key: io.kubernetes.pod.namespace
|
||||
operator: Exists
|
||||
icmps:
|
||||
- fields:
|
||||
- { type: 0, family: IPv4 }
|
||||
- { type: 3, family: IPv4 }
|
||||
- { type: 8, family: IPv4 }
|
||||
- { type: 11, family: IPv4 }
|
||||
- { type: 12, family: IPv4 }
|
||||
- { type: 1, family: IPv6 }
|
||||
- { type: 2, family: IPv6 }
|
||||
- { type: 3, family: IPv6 }
|
||||
- { type: 4, family: IPv6 }
|
||||
- { type: 128, family: IPv6 }
|
||||
- { type: 129, family: IPv6 }
|
||||
- { type: 133, family: IPv6 }
|
||||
- { type: 134, family: IPv6 }
|
||||
- { type: 135, family: IPv6 }
|
||||
- { type: 136, family: IPv6 }
|
||||
- { type: 143, family: IPv6 }
|
||||
egress:
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": paperclip
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": authentik
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": ingress
|
||||
app.kubernetes.io/name: traefik
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": rook-ceph
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": kube-system
|
||||
k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- toEntities:
|
||||
- host
|
||||
- remote-node
|
||||
# Cilium unions allows across CNPs, so world stays :443-only here too.
|
||||
- toEntities:
|
||||
- world
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "443"
|
||||
protocol: TCP
|
||||
- toEndpoints:
|
||||
- matchExpressions:
|
||||
- key: io.kubernetes.pod.namespace
|
||||
operator: Exists
|
||||
icmps:
|
||||
- fields:
|
||||
- { type: 0, family: IPv4 }
|
||||
- { type: 3, family: IPv4 }
|
||||
- { type: 8, family: IPv4 }
|
||||
- { type: 11, family: IPv4 }
|
||||
- { type: 12, family: IPv4 }
|
||||
- { type: 1, family: IPv6 }
|
||||
- { type: 2, family: IPv6 }
|
||||
- { type: 3, family: IPv6 }
|
||||
- { type: 4, family: IPv6 }
|
||||
- { type: 128, family: IPv6 }
|
||||
- { type: 129, family: IPv6 }
|
||||
- { type: 133, family: IPv6 }
|
||||
- { type: 134, family: IPv6 }
|
||||
- { type: 135, family: IPv6 }
|
||||
- { type: 136, family: IPv6 }
|
||||
- { type: 143, family: IPv6 }
|
||||
@@ -0,0 +1,30 @@
|
||||
# OpenBao policy: rotator-mosquitto-open3e-int
|
||||
#
|
||||
# Least-privilege policy for the mosquitto `open3e-int` broker-credential
|
||||
# rotation CronJob (kubernetes auth role `rotator-mosquitto-open3e-int`).
|
||||
# Per-secret isolation, same template as rotator-mosquitto-health.
|
||||
#
|
||||
# NOT applied by Flux. Apply ONCE with an admin token:
|
||||
#
|
||||
# bao policy write rotator-mosquitto-open3e-int \
|
||||
# kubernetes/infrastructure/openbao/policies/rotator-mosquitto-open3e-int.hcl
|
||||
#
|
||||
# bao write auth/kubernetes/role/rotator-mosquitto-open3e-int \
|
||||
# bound_service_account_names=mosquitto-open3e-int-secret-rotator \
|
||||
# bound_service_account_namespaces=openbao \
|
||||
# policies=rotator-mosquitto-open3e-int ttl=15m
|
||||
#
|
||||
# Do NOT set token_no_default_policy=true (kv-v2 patch preflight needs default).
|
||||
#
|
||||
# BOOTSTRAP: `random` mode rotates an existing field and never creates one.
|
||||
# Seed the path once with a `password` field before the first rotation:
|
||||
#
|
||||
# bao kv put -mount=homelab apps/home-assistant/mosquitto-open3e-int-credentials \
|
||||
# password=<generated>
|
||||
#
|
||||
# The consumer is the open3e-esp32 gateway on the heat pump's internal CAN
|
||||
# (NVS key mq_pass); a rotation takes effect on the device only after its
|
||||
# MQTT settings are updated (web UI or NVS image).
|
||||
path "homelab/data/apps/home-assistant/mosquitto-open3e-int-credentials" {
|
||||
capabilities = ["read", "patch"]
|
||||
}
|
||||
@@ -48,6 +48,7 @@ resources:
|
||||
- review-bot.yaml
|
||||
- claude-oauth.yaml
|
||||
- mosquitto-health.yaml
|
||||
- mosquitto-open3e-int.yaml
|
||||
# NOT ROTATED, deliberately — recorded here because the whole point of this
|
||||
# directory is that unrotated credentials should be a visible choice:
|
||||
#
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
# Class-A rotation of the mosquitto `open3e-int` broker credential: the
|
||||
# open3e-esp32 gateway on the Vitocal internal CAN (ECU 0x6e0). Same
|
||||
# rotator -> OpenBao -> ESO -> Reloader chain as mosquitto-health.yaml.
|
||||
#
|
||||
# The consumer is an embedded device: after a rotation its MQTT password must
|
||||
# be updated on the device (web UI Einstellungen, or an NVS image over USB).
|
||||
#
|
||||
# ON-DEMAND: kubectl -n openbao create job --from=cronjob/mosquitto-open3e-int-secret-rotate mosquitto-open3e-int-secret-rotate-now
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: mosquitto-open3e-int-secret-rotator
|
||||
namespace: openbao
|
||||
labels:
|
||||
app.kubernetes.io/name: mosquitto
|
||||
app.kubernetes.io/component: secret-rotator
|
||||
---
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: mosquitto-open3e-int-secret-rotate
|
||||
namespace: openbao
|
||||
labels:
|
||||
app.kubernetes.io/name: mosquitto
|
||||
app.kubernetes.io/component: secret-rotator
|
||||
spec:
|
||||
# Quarterly, own month offset (paperless: 1,4,7,10; health: 2,5,8,11).
|
||||
schedule: "0 4 1 3,6,9,12 *"
|
||||
timeZone: Europe/Berlin
|
||||
concurrencyPolicy: Forbid
|
||||
successfulJobsHistoryLimit: 2
|
||||
failedJobsHistoryLimit: 5
|
||||
startingDeadlineSeconds: 3600
|
||||
jobTemplate:
|
||||
spec:
|
||||
backoffLimit: 2
|
||||
activeDeadlineSeconds: 300
|
||||
ttlSecondsAfterFinished: 259200 # 3 d
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: mosquitto
|
||||
app.kubernetes.io/component: secret-rotator
|
||||
spec:
|
||||
serviceAccountName: mosquitto-open3e-int-secret-rotator
|
||||
restartPolicy: Never
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65534
|
||||
runAsGroup: 65534
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: rotate
|
||||
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-07-30
|
||||
args: ["random"]
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
env:
|
||||
- name: BAO_ADDR
|
||||
value: http://openbao.openbao.svc.cluster.local:8200
|
||||
- name: BAO_ROLE
|
||||
value: rotator-mosquitto-open3e-int
|
||||
- name: BAO_PATH
|
||||
value: homelab/apps/home-assistant/mosquitto-open3e-int-credentials
|
||||
- name: BAO_FIELDS
|
||||
value: "password"
|
||||
- name: RANDOM_BYTES
|
||||
value: "32"
|
||||
resources:
|
||||
requests:
|
||||
cpu: 25m
|
||||
memory: 16Mi
|
||||
limits:
|
||||
memory: 48Mi
|
||||
@@ -27,6 +27,7 @@ spec:
|
||||
- influxdb
|
||||
- ingress
|
||||
- monitoring
|
||||
- paperclip
|
||||
- paperless
|
||||
- trmnl
|
||||
- velero
|
||||
|
||||
@@ -321,3 +321,23 @@ spec:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: wordpress
|
||||
app.kubernetes.io/component: content
|
||||
---
|
||||
apiVersion: velero.io/v1
|
||||
kind: Schedule
|
||||
metadata:
|
||||
name: navidrome-daily
|
||||
namespace: velero
|
||||
labels:
|
||||
app.kubernetes.io/part-of: homelab-backup
|
||||
spec:
|
||||
# Daily at 09:15 local. Library (bulk) + SQLite data PVC.
|
||||
schedule: "15 9 * * *"
|
||||
useOwnerReferencesInBackup: false
|
||||
template:
|
||||
includedNamespaces:
|
||||
- navidrome
|
||||
includedResources:
|
||||
- persistentvolumeclaims
|
||||
storageLocation: default
|
||||
ttl: 720h0m0s
|
||||
snapshotMoveData: true
|
||||
|
||||
@@ -100,6 +100,12 @@ spec:
|
||||
# there for why this pool sat at 2 while the Crucials backed the OSDs.
|
||||
replicated:
|
||||
size: 3
|
||||
# Secondary EC 2+1 pool for bulk media; data0 stays the default pool.
|
||||
- name: bulk
|
||||
failureDomain: host
|
||||
erasureCoded:
|
||||
dataChunks: 2
|
||||
codingChunks: 1
|
||||
metadataServer:
|
||||
activeCount: 1
|
||||
activeStandby: true
|
||||
@@ -139,6 +145,25 @@ parameters:
|
||||
reclaimPolicy: Retain
|
||||
allowVolumeExpansion: true
|
||||
---
|
||||
# CephFS on the EC 2+1 bulk pool — large, rarely rewritten media files only.
|
||||
apiVersion: storage.k8s.io/v1
|
||||
kind: StorageClass
|
||||
metadata:
|
||||
name: ceph-filesystem-bulk
|
||||
provisioner: rook-ceph.cephfs.csi.ceph.com
|
||||
parameters:
|
||||
clusterID: rook-ceph
|
||||
fsName: cephfs
|
||||
pool: cephfs-bulk
|
||||
csi.storage.k8s.io/provisioner-secret-name: rook-csi-cephfs-provisioner
|
||||
csi.storage.k8s.io/provisioner-secret-namespace: rook-ceph
|
||||
csi.storage.k8s.io/controller-expand-secret-name: rook-csi-cephfs-provisioner
|
||||
csi.storage.k8s.io/controller-expand-secret-namespace: rook-ceph
|
||||
csi.storage.k8s.io/node-stage-secret-name: rook-csi-cephfs-node
|
||||
csi.storage.k8s.io/node-stage-secret-namespace: rook-ceph
|
||||
reclaimPolicy: Retain
|
||||
allowVolumeExpansion: true
|
||||
---
|
||||
# Same CephFS class but reclaimPolicy Delete — for THROWAWAY volumes only
|
||||
# (Woodpecker CI workspace PVCs). The Retain default exists so production
|
||||
# PVC deletions are undoable; CI workspaces are the opposite case, and with
|
||||
|
||||
Reference in New Issue
Block a user