Compare commits
92
Commits
@@ -199,7 +199,7 @@ steps:
|
||||
# separately rather than one this step quietly implies is covered.
|
||||
- name: shellcheck
|
||||
depends_on: [] # see note above `steps:`
|
||||
image: koalaman/shellcheck-alpine:v0.11.0
|
||||
image: koalaman/shellcheck-alpine:v0.11.0@sha256:9955be09ea7f0dbf7ae942ac1f2094355bb30d96fffba0ec09f5432207544002
|
||||
commands:
|
||||
- |
|
||||
set -eu
|
||||
@@ -319,7 +319,7 @@ steps:
|
||||
# writes its stripped mirror under mktemp and never into the workspace.
|
||||
- name: promtool
|
||||
depends_on: [] # see note above `steps:`
|
||||
image: prom/prometheus:v3.15.0
|
||||
image: prom/prometheus:v3.15.0@sha256:efd719c99d83b060d9daefdcf00360461adf279f45ef5391f8d111892118753e
|
||||
commands:
|
||||
- sh scripts/ci/promtool-rules.sh
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
when:
|
||||
# Deploy only on master (merge) + manual — never auto-deploy from a branch.
|
||||
- event: push
|
||||
branch: master
|
||||
path:
|
||||
- "ansible/roles/mqtt-bridge/**"
|
||||
- "ansible/plays/edge-mqtt-bridge.yml"
|
||||
- "ansible/inventory/group_vars/edge_host/**"
|
||||
- ".woodpecker/deploy-edge-mqtt-bridge.yml"
|
||||
- event: deployment
|
||||
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "deploy-edge-mqtt-bridge"'
|
||||
|
||||
timeout: 30
|
||||
|
||||
steps:
|
||||
- name: deploy-edge-mqtt-bridge
|
||||
image: gitea.renner.uno/gitea_admin/ci-ansible:2026-04-29
|
||||
environment:
|
||||
ANSIBLE_FORCE_COLOR: "true"
|
||||
ANSIBLE_HOST_KEY_CHECKING: "false"
|
||||
ANSIBLE_ROLES_PATH: "../roles:./roles"
|
||||
SSH_PRIVATE_KEY:
|
||||
from_secret: ssh_private_key
|
||||
VAULT_PASS:
|
||||
from_secret: vault_pass
|
||||
commands:
|
||||
- mkdir -p ~/.ssh
|
||||
- echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_ed25519
|
||||
- chmod 600 ~/.ssh/id_ed25519
|
||||
- printf "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null\n" > ~/.ssh/config
|
||||
- cd ansible
|
||||
- echo "$VAULT_PASS" > .vault_pass && chmod 600 .vault_pass
|
||||
- export ANSIBLE_VAULT_PASSWORD_FILE=.vault_pass
|
||||
# Local MQTT bridge on the edge hosts only (REN-1307); restarts only the
|
||||
# local mosquitto on edge-1, never the cluster broker.
|
||||
- ansible-playbook -i inventory/ plays/edge-mqtt-bridge.yml -v
|
||||
@@ -18,12 +18,12 @@ when:
|
||||
|
||||
steps:
|
||||
- name: iac-scan
|
||||
image: aquasec/trivy:latest
|
||||
image: aquasec/trivy:latest@sha256:af6acf9a6b85dfe389a1941505c0ce9efef52a4719635e1a962f022a3d855daa
|
||||
commands:
|
||||
- trivy fs --format json -o trivy-iac.json --severity CRITICAL,HIGH --ignore-unfixed .
|
||||
|
||||
- name: image-scans
|
||||
image: aquasec/trivy:latest
|
||||
image: aquasec/trivy:latest@sha256:af6acf9a6b85dfe389a1941505c0ce9efef52a4719635e1a962f022a3d855daa
|
||||
commands:
|
||||
- trivy image --download-db-only
|
||||
- |
|
||||
|
||||
@@ -58,7 +58,8 @@ target `build-agent-runtime`), versions bumped by Renovate. Both also
|
||||
carry `claude-agent-acp` for the ACP engine, pinned and patched like
|
||||
Paperclip ships it, SDK override included
|
||||
(`ci/images/agent-runtime-common/`); bump it by hand
|
||||
together with Paperclip. All tools are on the default `PATH`.
|
||||
together with Paperclip. All tools are on the default `PATH`. Both add
|
||||
`tzdata` (the base has no zoneinfo); the default zone stays UTC.
|
||||
|
||||
| Image | `imageRegistry` | Adds |
|
||||
|---|---|---|
|
||||
|
||||
@@ -62,7 +62,6 @@ inventory change.
|
||||
|---|---|---|
|
||||
| Innomaker USB2CAN (Vitocal/VX3 + E380) | `heating_can` | `plays/install-heating-can.yml` |
|
||||
| Nabu Casa ZWA-2 Z-Wave radio | `zwave_gateway` | `plays/install-zwave-gateway.yml` |
|
||||
| FTDI serial power-meter head | `power_meter` | `plays/install-power-meter.yml` |
|
||||
|
||||
Add the host to the matching group, then run that playbook. They depend on
|
||||
the base play having run: all three need the local MQTT bridge, and two of
|
||||
@@ -87,7 +86,6 @@ kubectl -n home-assistant get secret zwave-js-session -o jsonpath='{.data.sessio
|
||||
|
||||
```yaml
|
||||
# vault.yml
|
||||
vault_edge_sml2mqtt_mqtt_password: "..."
|
||||
vault_edge_open3e_mqtt_password: "..."
|
||||
vault_edge_zwavejs_session_secret: "..."
|
||||
vault_edge_loki_password: "..." # dedicated edge-1 Loki push cred (see "Logging & eMMC")
|
||||
@@ -238,6 +236,21 @@ cluster-side change was needed**: one authenticates as the `open3e` user
|
||||
out). The local broker is `allow_anonymous` on loopback — the trust boundary
|
||||
is the host; nothing off-host can reach it.
|
||||
|
||||
**Bridge credential (REN-1307).** The two vault-password bridges are being
|
||||
replaced by one bridge as the register account `edge-1`
|
||||
(`kubernetes/apps/home-assistant/mqtt-accounts.yaml`). Its password is pushed
|
||||
by mqtt-account-controller to `edge-mqtt-cred` (`:8443`, the controller's
|
||||
esp32 API over HTTPS). That endpoint accepts only a client certificate from
|
||||
the MQTT CA with CN `mqtt-account-controller`; edge-1 holds the public CA and
|
||||
its own server key, nothing else. On a push it writes
|
||||
`/etc/mosquitto/bridge.d/edge-1.conf`, deletes `bridge.d/legacy.conf` and
|
||||
restarts the local mosquitto. The controller sets a new password whenever the
|
||||
bridge reports "not connected" (also after a reinstall), so no play run is
|
||||
needed for a rotation. `.woodpecker/deploy-edge-mqtt-bridge.yml` runs
|
||||
`plays/edge-mqtt-bridge.yml` on merge. Diagnosis: `journalctl -u
|
||||
edge-mqtt-cred`, and `mosquitto_sub -t '$SYS/broker/connection/edge-1/state'`
|
||||
(retained `1` = connected).
|
||||
|
||||
**eMMC:** the local broker writes **nothing** to flash — `persistence false`
|
||||
(retained/queued messages in RAM) and `log_dest stdout` (to the volatile
|
||||
journal → Loki). Do not add persistence or log-file settings to
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
# Secret indirection — real values live in vault.yml (ansible-vault) next
|
||||
# to this file; see docs/edge-host.md for how to populate it from
|
||||
# the cluster secrets.
|
||||
edge_sml2mqtt_mqtt_password: "{{ vault_edge_sml2mqtt_mqtt_password }}"
|
||||
edge_open3e_mqtt_password: "{{ vault_edge_open3e_mqtt_password }}"
|
||||
edge_zwavejs_session_secret: "{{ vault_edge_zwavejs_session_secret }}"
|
||||
# Loki push credential (dedicated 'edge-1' user, Traefik basic-auth on the
|
||||
@@ -39,8 +38,8 @@ chrony_hwtimestamp: false
|
||||
|
||||
# --- Local MQTT contract -------------------------------------------------
|
||||
# Shared between the broker and its clients, so it belongs to the inventory
|
||||
# rather than to either side's role. mqtt-bridge listens here; heating-can and
|
||||
# power-meter-sml publish here; and the bridge authenticates to the cluster
|
||||
# rather than to either side's role. mqtt-bridge listens here; heating-can
|
||||
# publishes here; and the bridge authenticates to the cluster
|
||||
# broker AS the open3e user, so it needs that identity too.
|
||||
#
|
||||
# Keeping these in one role's defaults only worked while a single play loaded
|
||||
|
||||
@@ -41,13 +41,9 @@ edge-1.iot.renner.uno
|
||||
[heating_can]
|
||||
edge-1.iot.renner.uno
|
||||
|
||||
[power_meter]
|
||||
edge-1.iot.renner.uno
|
||||
|
||||
[edge_host:children]
|
||||
zwave_gateway
|
||||
heating_can
|
||||
power_meter
|
||||
|
||||
# NTP servers. edge-1 was added here so the role would survive the PVE hosts
|
||||
# being retired for bare-metal Talos, which cannot run chrony or keepalived
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
# Local MQTT bridge and its credential receiver only (REN-1307). Run by
|
||||
# .woodpecker/deploy-edge-mqtt-bridge.yml on merge, so a change to this role
|
||||
# reaches edge-1 without a play run by hand. The full base play
|
||||
# (install-edge-host.yml) carries the same role.
|
||||
- name: Converge the edge MQTT bridge
|
||||
hosts: edge_host
|
||||
become: true
|
||||
|
||||
# The SML power-meter reader (sml2mqtt) is retired for good: owner decision
|
||||
# 2026-09-19, grid data comes from the E380 on the CAN bus. Its role and
|
||||
# play are gone; this removes the leftover compose project from edge-1 on
|
||||
# the next pipeline run. Idempotent: a no-op once the directory is gone.
|
||||
pre_tasks:
|
||||
- name: Check for the retired sml2mqtt compose project
|
||||
ansible.builtin.stat:
|
||||
path: /opt/edge-host/sml2mqtt/docker-compose.yml
|
||||
register: edge_sml2mqtt_compose
|
||||
|
||||
- name: Stop the retired sml2mqtt container
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose down --remove-orphans
|
||||
chdir: /opt/edge-host/sml2mqtt
|
||||
when: edge_sml2mqtt_compose.stat.exists
|
||||
changed_when: true
|
||||
|
||||
- name: Remove the retired sml2mqtt directory
|
||||
ansible.builtin.file:
|
||||
path: /opt/edge-host/sml2mqtt
|
||||
state: absent
|
||||
|
||||
roles:
|
||||
- mqtt-bridge
|
||||
@@ -9,7 +9,6 @@
|
||||
#
|
||||
# plays/install-zwave-gateway.yml [zwave_gateway] ZWA-2 radio
|
||||
# plays/install-heating-can.yml [heating_can] Innomaker USB2CAN
|
||||
# plays/install-power-meter.yml [power_meter] FTDI meter head
|
||||
#
|
||||
# Splitting the roles without splitting the plays would have kept the coupling:
|
||||
# one play applying every role to one group is the monolith again, a layer up.
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
---
|
||||
# Power-meter reader (sml2mqtt) for hosts with the FTDI serial meter head
|
||||
# attached.
|
||||
#
|
||||
# Targets [power_meter]: membership means the meter head is physically present.
|
||||
#
|
||||
# Requires the base play (plays/install-edge-host.yml) to have run: it needs
|
||||
# docker and publishes to the local MQTT bridge.
|
||||
- name: Install power-meter reader
|
||||
hosts: power_meter
|
||||
become: true
|
||||
|
||||
roles:
|
||||
- power-meter-sml
|
||||
@@ -10,3 +10,14 @@
|
||||
edge_mqtt_cluster_host: mqtt.renner.uno
|
||||
edge_mqtt_cluster_tls_port: 8883
|
||||
edge_mqtt_bridge_cafile: /etc/ssl/certs/ca-certificates.crt
|
||||
|
||||
# --- Bridge credential from mqtt-account-controller (REN-1307) ------------
|
||||
# The cluster account the bridge logs in as (register entry in
|
||||
# kubernetes/apps/home-assistant/mqtt-accounts.yaml). The controller pushes
|
||||
# its password to edge-mqtt-cred and rotates it when the bridge reports
|
||||
# "not connected"; there is no copy in ansible-vault.
|
||||
edge_mqtt_bridge_account: edge-1
|
||||
# HTTPS port of edge-mqtt-cred (esp32 device API, mTLS). Only a client
|
||||
# certificate from the MQTT CA with this CN is accepted.
|
||||
edge_mqtt_cred_port: 8443
|
||||
edge_mqtt_cred_allowed_cn: mqtt-account-controller
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env python3
|
||||
# Managed by ansible (roles/mqtt-bridge). Receives the cluster bridge password
|
||||
# from mqtt-account-controller (REN-1307) so no plaintext copy lives in
|
||||
# ansible-vault and no play run is needed to rotate it.
|
||||
#
|
||||
# Speaks the controller's esp32 device API over HTTPS:
|
||||
# GET /api/status -> {"mqtt": {"connected": bool}}
|
||||
# PUT /api/settings <- {"mqtt": {"user": "...", "pass": "..."}}
|
||||
# A client certificate from the MQTT CA with CN mqtt-account-controller is
|
||||
# required; nothing else may call it. The server key is generated on this
|
||||
# host and never leaves it (the controller pins it on first contact).
|
||||
#
|
||||
# PUT writes the bridge file (template + credentials), removes the legacy
|
||||
# vault-password bridges and restarts the local mosquitto. The password is
|
||||
# kept only in that 0640 root:mosquitto file.
|
||||
import grp
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
CONF = json.load(open(sys.argv[1]))
|
||||
WRITE = threading.Lock()
|
||||
|
||||
|
||||
def connected():
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["mosquitto_sub", "-h", "127.0.0.1", "-p", str(CONF["local_port"]),
|
||||
"-t", CONF["state_topic"], "-C", "1", "-W", "3"],
|
||||
capture_output=True, text=True, timeout=10).stdout
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
return out.strip() == "1"
|
||||
|
||||
|
||||
def write_bridge(user, password):
|
||||
with open(CONF["template"]) as f:
|
||||
body = f.read()
|
||||
body += f"remote_username {user}\nremote_password {password}\n"
|
||||
d = os.path.dirname(CONF["bridge_file"])
|
||||
fd, tmp = tempfile.mkstemp(dir=d, prefix=".edge-mqtt-cred.")
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(body)
|
||||
os.chown(tmp, 0, grp.getgrnam("mosquitto").gr_gid)
|
||||
os.chmod(tmp, 0o640)
|
||||
os.replace(tmp, CONF["bridge_file"])
|
||||
for legacy in CONF["legacy_files"]:
|
||||
if os.path.exists(legacy):
|
||||
os.remove(legacy)
|
||||
subprocess.run(["systemctl", "restart", "mosquitto"], check=True, timeout=60)
|
||||
|
||||
|
||||
def valid(s):
|
||||
# Controller passwords are random URL-safe tokens; anything with
|
||||
# whitespace or a control character would break the config line.
|
||||
return isinstance(s, str) and 0 < len(s) <= 256 and s.isprintable() and not any(c.isspace() for c in s)
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def authorized(self):
|
||||
cert = self.connection.getpeercert() or {}
|
||||
cns = [v for rdn in cert.get("subject", ()) for k, v in rdn if k == "commonName"]
|
||||
if cns != [CONF["allowed_cn"]]:
|
||||
self.send_error(403)
|
||||
return False
|
||||
return True
|
||||
|
||||
def reply(self, code, obj=None):
|
||||
data = b"" if obj is None else json.dumps(obj).encode()
|
||||
self.send_response(code)
|
||||
if data:
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def do_GET(self):
|
||||
if not self.authorized():
|
||||
return
|
||||
if self.path != "/api/status":
|
||||
return self.send_error(404)
|
||||
self.reply(200, {"mqtt": {"connected": connected()}})
|
||||
|
||||
def do_PUT(self):
|
||||
if not self.authorized():
|
||||
return
|
||||
if self.path != "/api/settings":
|
||||
return self.send_error(404)
|
||||
try:
|
||||
n = int(self.headers.get("Content-Length", "0"))
|
||||
if not 0 < n <= 4096:
|
||||
raise ValueError("body size")
|
||||
m = json.loads(self.rfile.read(n))["mqtt"]
|
||||
user, password = m["user"], m["pass"]
|
||||
except (ValueError, KeyError, TypeError):
|
||||
return self.send_error(400)
|
||||
if user != CONF["user"] or not valid(password):
|
||||
return self.send_error(400)
|
||||
with WRITE:
|
||||
write_bridge(user, password)
|
||||
self.log_message("bridge credentials for %s replaced, mosquitto restarted", user)
|
||||
self.reply(204)
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
sys.stderr.write("%s %s\n" % (self.address_string(), fmt % args))
|
||||
|
||||
|
||||
class Server(ThreadingHTTPServer):
|
||||
address_family = socket.AF_INET6
|
||||
|
||||
def server_bind(self):
|
||||
self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
|
||||
super().server_bind()
|
||||
|
||||
def get_request(self):
|
||||
# The TLS handshake runs in the request thread, with a timeout, so a
|
||||
# stalled peer cannot block the accept loop.
|
||||
sock, addr = self.socket.accept()
|
||||
sock.settimeout(10)
|
||||
return self.ctx.wrap_socket(sock, server_side=True, do_handshake_on_connect=False), addr
|
||||
|
||||
|
||||
def main():
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
ctx.load_cert_chain(CONF["cert"], CONF["key"])
|
||||
ctx.load_verify_locations(CONF["ca"])
|
||||
ctx.verify_mode = ssl.CERT_REQUIRED
|
||||
srv = Server(("::", CONF["port"]), Handler)
|
||||
srv.ctx = ctx
|
||||
srv.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,10 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBdTCCARygAwIBAgIUN8ne4k4Wif9O83GDyKiBP0Ma7EIwCgYIKoZIzj0EAwIw
|
||||
GTEXMBUGA1UEAxMOUmVubmVyIE1RVFQgQ0EwHhcNMjYxMDA2MTUzMDE1WhcNMzYx
|
||||
MDAzMTUzMDE1WjAZMRcwFQYDVQQDEw5SZW5uZXIgTVFUVCBDQTBZMBMGByqGSM49
|
||||
AgEGCCqGSM49AwEHA0IABOZhZF2JDoyvVg2oIXSbJAuAWDcoG22RxPA4oMKRMA/b
|
||||
XzIY566D2k6cVnWqbil9s/q4O2mj+Epo7plBw8NwuOOjQjBAMA4GA1UdDwEB/wQE
|
||||
AwICpDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBQcqSDDiLZzhrvqq9f16Y08
|
||||
CO7QfzAKBggqhkjOPQQDAgNHADBEAiBFkxd9pXnqIcjReEcOTvCBZ3HxKu2jNQGL
|
||||
/le7gunTGgIgZapWAcu4awa+wmZcR/z2luOjD2GhIT15um14Z+sstJk=
|
||||
-----END CERTIFICATE-----
|
||||
@@ -5,3 +5,9 @@
|
||||
ansible.builtin.service:
|
||||
name: mosquitto
|
||||
state: restarted
|
||||
|
||||
- name: Restart edge-mqtt-cred
|
||||
ansible.builtin.systemd:
|
||||
name: edge-mqtt-cred
|
||||
state: restarted
|
||||
daemon_reload: true
|
||||
|
||||
@@ -11,13 +11,39 @@
|
||||
name: mosquitto
|
||||
state: present
|
||||
|
||||
- name: Deploy local mosquitto bridge config
|
||||
- name: Create the bridge directory
|
||||
ansible.builtin.file:
|
||||
path: /etc/mosquitto/bridge.d
|
||||
state: directory
|
||||
owner: root
|
||||
group: mosquitto
|
||||
mode: "0750"
|
||||
|
||||
- name: Check for the controller-managed bridge file
|
||||
ansible.builtin.stat:
|
||||
path: "/etc/mosquitto/bridge.d/{{ edge_mqtt_bridge_account }}.conf"
|
||||
register: edge_mqtt_bridge_managed
|
||||
|
||||
# Until mqtt-account-controller has pushed the first password, keep the
|
||||
# vault-password bridges so the cluster never loses edge-1's data. Once the
|
||||
# managed file exists, never render them again (edge-mqtt-cred removed them).
|
||||
- name: Deploy legacy vault-password bridges
|
||||
ansible.builtin.template:
|
||||
src: edge-mqtt-bridge-legacy.conf.j2
|
||||
dest: /etc/mosquitto/bridge.d/legacy.conf
|
||||
owner: root
|
||||
group: mosquitto
|
||||
mode: "0640" # carries the legacy cluster bridge credentials
|
||||
when: not edge_mqtt_bridge_managed.stat.exists
|
||||
notify: Restart mosquitto
|
||||
|
||||
- name: Deploy local mosquitto config
|
||||
ansible.builtin.template:
|
||||
src: edge-mqtt-bridge.conf.j2
|
||||
dest: /etc/mosquitto/mosquitto.conf
|
||||
owner: root
|
||||
group: mosquitto
|
||||
mode: "0640" # carries the cluster bridge credentials
|
||||
mode: "0640"
|
||||
notify: Restart mosquitto
|
||||
|
||||
- name: Enable local mosquitto bridge
|
||||
@@ -25,3 +51,59 @@
|
||||
name: mosquitto
|
||||
enabled: true
|
||||
state: started
|
||||
|
||||
# --- edge-mqtt-cred (REN-1307): receives the bridge password from
|
||||
# mqtt-account-controller. edge-1 holds only the public MQTT CA and its own
|
||||
# server key, generated here and never copied off the host.
|
||||
- name: Create edge-mqtt-cred config directory
|
||||
ansible.builtin.file:
|
||||
path: /etc/edge-mqtt-cred
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0700"
|
||||
|
||||
- name: Generate edge-mqtt-cred server key and certificate
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1
|
||||
-nodes -days 36500 -subj "/CN={{ inventory_hostname }}"
|
||||
-keyout /etc/edge-mqtt-cred/tls.key -out /etc/edge-mqtt-cred/tls.crt
|
||||
creates: /etc/edge-mqtt-cred/tls.key
|
||||
notify: Restart edge-mqtt-cred
|
||||
|
||||
- name: Deploy edge-mqtt-cred files
|
||||
ansible.builtin.copy:
|
||||
src: "{{ item.src }}"
|
||||
dest: "{{ item.dest }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "{{ item.mode }}"
|
||||
loop:
|
||||
- {src: edge-mqtt-cred.py, dest: /usr/local/sbin/edge-mqtt-cred, mode: "0755"}
|
||||
# Public CA of the cluster's MQTT client certificates
|
||||
# (kubernetes/infrastructure/post-crds/mqtt-ca.yaml), valid until 2036-10-03.
|
||||
- {src: mqtt-ca.crt, dest: /etc/edge-mqtt-cred/mqtt-ca.crt, mode: "0644"}
|
||||
notify: Restart edge-mqtt-cred
|
||||
|
||||
- name: Deploy edge-mqtt-cred config and bridge template
|
||||
ansible.builtin.template:
|
||||
src: "{{ item.src }}"
|
||||
dest: "{{ item.dest }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "{{ item.mode }}"
|
||||
loop:
|
||||
- {src: edge-mqtt-cred.json.j2, dest: /etc/edge-mqtt-cred/config.json, mode: "0600"}
|
||||
# Read only on the controller's next push: a change here reaches the
|
||||
# running bridge when the password is next set.
|
||||
- {src: edge-1-bridge.conf.in.j2, dest: /etc/edge-mqtt-cred/bridge.conf.in, mode: "0600"}
|
||||
- {src: edge-mqtt-cred.service.j2, dest: /etc/systemd/system/edge-mqtt-cred.service, mode: "0644"}
|
||||
notify: Restart edge-mqtt-cred
|
||||
|
||||
- name: Enable edge-mqtt-cred
|
||||
ansible.builtin.systemd:
|
||||
name: edge-mqtt-cred
|
||||
enabled: true
|
||||
state: started
|
||||
daemon_reload: true
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Managed by roles/mqtt-bridge (REN-1307). edge-mqtt-cred appends
|
||||
# remote_username/remote_password to this and writes the result to
|
||||
# /etc/mosquitto/bridge.d/{{ edge_mqtt_bridge_account }}.conf. One bridge for
|
||||
# open3e, as the register account `{{ edge_mqtt_bridge_account }}`
|
||||
# (ACL readwrite open3e/#). sml2mqtt is retired (SML reader, 2026-09-19).
|
||||
connection {{ edge_mqtt_bridge_account }}
|
||||
address {{ edge_mqtt_cluster_host }}:{{ edge_mqtt_cluster_tls_port }}
|
||||
bridge_cafile {{ edge_mqtt_bridge_cafile }}
|
||||
bridge_insecure false
|
||||
remote_clientid edge1-bridge
|
||||
cleansession false
|
||||
try_private true
|
||||
restart_timeout 5 30
|
||||
queue_qos0_messages true
|
||||
# The bridge state goes to the LOCAL broker only (retained 1/0); edge-mqtt-cred
|
||||
# reads it to answer the controller's "connected?".
|
||||
notifications true
|
||||
notifications_local_only true
|
||||
notification_topic $SYS/broker/connection/{{ edge_mqtt_bridge_account }}/state
|
||||
topic open3e/# out 0
|
||||
topic open3e/cmnd in 0
|
||||
@@ -0,0 +1,21 @@
|
||||
# Managed by roles/mqtt-bridge. Legacy vault-password bridges, rendered only
|
||||
# while /etc/mosquitto/bridge.d/{{ edge_mqtt_bridge_account }}.conf does not
|
||||
# exist; edge-mqtt-cred deletes this file on the controller's first push
|
||||
# (REN-1307). Remove with the vault keys once edge-1 is on the register account.
|
||||
# --- Bridge 1: open3e datapoints + command topic, as the cluster `open3e`
|
||||
# user (its ACL is `readwrite open3e/#`, which covers publishing datapoints
|
||||
# and subscribing to open3e/cmnd). e3oncan also publishes under open3e/#.
|
||||
connection edge1-open3e
|
||||
address {{ edge_mqtt_cluster_host }}:{{ edge_mqtt_cluster_tls_port }}
|
||||
bridge_cafile {{ edge_mqtt_bridge_cafile }}
|
||||
bridge_insecure false
|
||||
remote_username {{ edge_open3e_mqtt_user }}
|
||||
remote_password {{ edge_open3e_mqtt_password }}
|
||||
remote_clientid edge1-bridge-open3e
|
||||
cleansession false
|
||||
try_private true
|
||||
notifications false
|
||||
restart_timeout 5 30
|
||||
queue_qos0_messages true
|
||||
topic open3e/# out 0
|
||||
topic open3e/cmnd in 0
|
||||
@@ -22,36 +22,9 @@ log_type notice
|
||||
listener 1883 127.0.0.1
|
||||
allow_anonymous true
|
||||
|
||||
# --- Bridge 1: open3e datapoints + command topic, as the cluster `open3e`
|
||||
# user (its ACL is `readwrite open3e/#`, which covers publishing datapoints
|
||||
# and subscribing to open3e/cmnd). e3oncan also publishes under open3e/#.
|
||||
connection edge1-open3e
|
||||
address {{ edge_mqtt_cluster_host }}:{{ edge_mqtt_cluster_tls_port }}
|
||||
bridge_cafile {{ edge_mqtt_bridge_cafile }}
|
||||
bridge_insecure false
|
||||
remote_username {{ edge_open3e_mqtt_user }}
|
||||
remote_password {{ edge_open3e_mqtt_password }}
|
||||
remote_clientid edge1-bridge-open3e
|
||||
cleansession false
|
||||
try_private true
|
||||
notifications false
|
||||
restart_timeout 5 30
|
||||
queue_qos0_messages true
|
||||
topic open3e/# out 0
|
||||
topic open3e/cmnd in 0
|
||||
|
||||
# --- Bridge 2: power-meter topics, as the cluster `sml2mqtt` user (ACL
|
||||
# `write sml2mqtt/#`). Publish-only, so no `in` topics.
|
||||
connection edge1-sml2mqtt
|
||||
address {{ edge_mqtt_cluster_host }}:{{ edge_mqtt_cluster_tls_port }}
|
||||
bridge_cafile {{ edge_mqtt_bridge_cafile }}
|
||||
bridge_insecure false
|
||||
remote_username sml2mqtt
|
||||
remote_password {{ edge_sml2mqtt_mqtt_password }}
|
||||
remote_clientid edge1-bridge-sml2mqtt
|
||||
cleansession false
|
||||
try_private true
|
||||
notifications false
|
||||
restart_timeout 5 30
|
||||
queue_qos0_messages true
|
||||
topic sml2mqtt/# out 0
|
||||
# --- Bridges to the cluster broker (REN-1307). bridge.d holds either
|
||||
# {{ edge_mqtt_bridge_account }}.conf, written by edge-mqtt-cred with the password
|
||||
# mqtt-account-controller pushed, or, until that first push, legacy.conf with
|
||||
# the two vault-password bridges. edge-mqtt-cred deletes legacy.conf when it
|
||||
# writes the new file.
|
||||
include_dir /etc/mosquitto/bridge.d
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{{ {
|
||||
"port": edge_mqtt_cred_port,
|
||||
"allowed_cn": edge_mqtt_cred_allowed_cn,
|
||||
"user": edge_mqtt_bridge_account,
|
||||
"cert": "/etc/edge-mqtt-cred/tls.crt",
|
||||
"key": "/etc/edge-mqtt-cred/tls.key",
|
||||
"ca": "/etc/edge-mqtt-cred/mqtt-ca.crt",
|
||||
"template": "/etc/edge-mqtt-cred/bridge.conf.in",
|
||||
"bridge_file": "/etc/mosquitto/bridge.d/" ~ edge_mqtt_bridge_account ~ ".conf",
|
||||
"legacy_files": ["/etc/mosquitto/bridge.d/legacy.conf"],
|
||||
"local_port": edge_mqtt_port,
|
||||
"state_topic": "$SYS/broker/connection/" ~ edge_mqtt_bridge_account ~ "/state",
|
||||
} | to_nice_json }}
|
||||
@@ -0,0 +1,19 @@
|
||||
# Managed by roles/mqtt-bridge (REN-1307). See /usr/local/sbin/edge-mqtt-cred.
|
||||
[Unit]
|
||||
Description=Receives the MQTT bridge credential from mqtt-account-controller
|
||||
After=network-online.target mosquitto.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/bin/python3 /usr/local/sbin/edge-mqtt-cred /etc/edge-mqtt-cred/config.json
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
# Root only to write the bridge file and restart mosquitto.
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/etc/mosquitto/bridge.d
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
NoNewPrivileges=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
# Power-meter reader: sml2mqtt against the FTDI serial meter head.
|
||||
|
||||
# Service root. Kept per-role so the role is usable on its own.
|
||||
edge_base_dir: /opt/edge-host
|
||||
|
||||
# Own compose project directory. Unchanged from the path used under the
|
||||
# previous shared project.
|
||||
sml_base_dir: "{{ edge_base_dir }}/sml2mqtt"
|
||||
|
||||
# The compose file that used to hold both this service and zwave-js. Removed
|
||||
# once, so the fixed container_name is released for the new projects.
|
||||
edge_legacy_compose: "{{ edge_base_dir }}/docker-compose.yml"
|
||||
|
||||
# Stable by-id symlink -- see the zwave-gateway role.
|
||||
edge_stromzaehler_device: /dev/serial/by-id/usb-FTDI_FT230X_Basic_UART_DK0E1GP5-if00-port0
|
||||
|
||||
# Hand-pinned on purpose: critical meter bridge, deliberately excluded from
|
||||
# image automation.
|
||||
edge_sml2mqtt_image: spacemanspiff2007/sml2mqtt:3.6.0
|
||||
|
||||
# Secret from inventory/group_vars/edge_host/vault.yml:
|
||||
# edge_sml2mqtt_mqtt_password
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
- name: Restart sml2mqtt
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose up -d --force-recreate
|
||||
chdir: "{{ sml_base_dir }}"
|
||||
changed_when: true
|
||||
@@ -1,55 +0,0 @@
|
||||
---
|
||||
# Power-meter reader: sml2mqtt against the FTDI serial meter head.
|
||||
#
|
||||
# Its own compose project. It shared a compose file with the Z-Wave gateway,
|
||||
# which meant a change to either recreated both -- two unrelated services bound
|
||||
# to different USB devices. Data paths are unchanged.
|
||||
|
||||
- name: Create sml2mqtt directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ sml_base_dir }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
# sml2mqtt reads only a config file and has no env override for the password,
|
||||
# so the rendered config carries it -- 0600, root-owned, same trust boundary as
|
||||
# the k8s init-container approach it replaced.
|
||||
- name: Deploy sml2mqtt config
|
||||
ansible.builtin.template:
|
||||
src: sml2mqtt-config.yml.j2
|
||||
dest: "{{ sml_base_dir }}/config.yml"
|
||||
mode: "0600"
|
||||
notify: Restart sml2mqtt
|
||||
|
||||
- name: Deploy sml2mqtt compose project
|
||||
ansible.builtin.template:
|
||||
src: docker-compose.yml.j2
|
||||
dest: "{{ sml_base_dir }}/docker-compose.yml"
|
||||
mode: "0644"
|
||||
notify: Restart sml2mqtt
|
||||
|
||||
- name: Pull sml2mqtt image
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose pull
|
||||
chdir: "{{ sml_base_dir }}"
|
||||
changed_when: false
|
||||
|
||||
# Tolerates a missing USB device, as for the Z-Wave gateway.
|
||||
- name: Start sml2mqtt
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose up -d
|
||||
chdir: "{{ sml_base_dir }}"
|
||||
register: sml_compose_up
|
||||
failed_when: >-
|
||||
sml_compose_up.rc != 0
|
||||
and 'error gathering device information' not in sml_compose_up.stderr
|
||||
changed_when: >-
|
||||
'Started' in sml_compose_up.stderr or 'Created' in sml_compose_up.stderr
|
||||
or 'Started' in sml_compose_up.stdout or 'Creating' in sml_compose_up.stdout
|
||||
|
||||
- name: Warn if sml2mqtt is waiting for its USB device
|
||||
ansible.builtin.debug:
|
||||
msg: >-
|
||||
sml2mqtt could not start -- the FTDI meter head is not present:
|
||||
{{ sml_compose_up.stderr_lines | select('search', 'device') | list }}
|
||||
when: sml_compose_up.rc != 0
|
||||
@@ -1,21 +0,0 @@
|
||||
# Managed by ansible (roles/power-meter-sml).
|
||||
#
|
||||
# Its own compose project -- see the role tasks for why it no longer shares a
|
||||
# file with the Z-Wave gateway.
|
||||
#
|
||||
# Outbound-only (serial in, MQTT out). Host networking sidesteps the docker
|
||||
# NAT66 gap: the broker LB is IPv6-first and bridge-network v6 SYNs leave
|
||||
# unmasqueraded and die. The host stack reaches it fine (open3e proves it
|
||||
# continuously).
|
||||
services:
|
||||
sml2mqtt:
|
||||
image: {{ edge_sml2mqtt_image }}
|
||||
container_name: sml2mqtt
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
# Serial exclusivity: this is the ONLY reader of the meter port --
|
||||
# HA's edl21 integration stays disabled.
|
||||
devices:
|
||||
- "{{ edge_stromzaehler_device }}:/dev/stromzaehler"
|
||||
volumes:
|
||||
- ./config.yml:/sml2mqtt/config.yml:ro
|
||||
@@ -1,32 +0,0 @@
|
||||
# Managed by ansible (roles/power-meter-sml). Ported 1:1 from the k8s ConfigMap
|
||||
# sml2mqtt-config; only host and password delivery differ (broker is now
|
||||
# reached via its LoadBalancer hostname, password rendered by ansible
|
||||
# instead of an init container).
|
||||
logging:
|
||||
level: INFO
|
||||
file: stdout # 'stdout' -> docker logs (NOT '' — that opens the dir)
|
||||
|
||||
mqtt:
|
||||
connection:
|
||||
identifier: sml2mqtt-meter
|
||||
host: {{ edge_mqtt_host }}
|
||||
port: {{ edge_mqtt_port }}
|
||||
user: sml2mqtt
|
||||
password: {{ edge_sml2mqtt_mqtt_password }}
|
||||
topic prefix: sml2mqtt
|
||||
defaults:
|
||||
qos: 0
|
||||
retain: false
|
||||
last will:
|
||||
topic: status
|
||||
|
||||
general:
|
||||
Wh in kWh: false # keep raw Wh to match edl21 history — HA long-term
|
||||
# stats continue with zero unit discontinuity
|
||||
republish after: 120 # re-emit unchanged values every 120 s
|
||||
|
||||
inputs:
|
||||
- type: serial
|
||||
url: /dev/stromzaehler # FTDI FT230X, mapped by docker compose
|
||||
timeout: 3
|
||||
# 9600 8N1 are sml2mqtt defaults for SML/EDL21 — no override needed.
|
||||
@@ -30,6 +30,15 @@ RUN ln -s /usr/local/go/bin/go /usr/local/bin/go \
|
||||
&& ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt
|
||||
USER 1000:1000
|
||||
|
||||
# Time zones (REN-1196): the base has no /usr/share/zoneinfo, so
|
||||
# TZ=Europe/Berlin silently prints UTC and Python ZoneInfo() raises. The
|
||||
# default zone stays UTC; agents set TZ where they need local time.
|
||||
USER root
|
||||
RUN apt-get update \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
USER 1000:1000
|
||||
|
||||
# ACP server the Paperclip ACP engine starts in the pod (REN-302). Version and
|
||||
# patch match what Paperclip ships: packages/adapters/claude-local/package.json
|
||||
# pins 0.73.0 and applies patches/@agentclientprotocol__claude-agent-acp@0.73.0.patch
|
||||
@@ -63,6 +72,8 @@ ENV PATH=/usr/local/go/bin:$PATH \
|
||||
|
||||
# Base user (1000:1000), entrypoint (tini) and cmd (agent shim) are inherited.
|
||||
# The PATH check uses the base image's default PATH, not the ENV above.
|
||||
RUN go version && golangci-lint version \
|
||||
RUN TZ=Europe/Berlin date +%Z | grep -Eq '^CES?T$' \
|
||||
&& python3 -c "from zoneinfo import ZoneInfo; ZoneInfo('Europe/Berlin')" \
|
||||
&& go version && golangci-lint version \
|
||||
&& env -i PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
|
||||
sh -ec 'for t in go gofmt golangci-lint govulncheck claude-agent-acp; do command -v "$t"; done'
|
||||
|
||||
@@ -21,6 +21,16 @@ COPY --from=flux /usr/local/bin/flux /usr/local/bin/flux
|
||||
COPY --from=kustomize /app/kustomize /usr/local/bin/kustomize
|
||||
COPY --from=helm /usr/bin/helm /usr/local/bin/helm
|
||||
|
||||
# Time zones (REN-1196): the base has no /usr/share/zoneinfo, so
|
||||
# TZ=Europe/Berlin silently prints UTC and Python ZoneInfo() raises. The
|
||||
# default zone stays UTC; agents set TZ where they need local time.
|
||||
# jq (REN-1345): the agents' duty lists and the Paperclip skill use it.
|
||||
USER root
|
||||
RUN apt-get update \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends tzdata jq \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
USER 1000:1000
|
||||
|
||||
# ACP server the Paperclip ACP engine starts in the pod (REN-302). Version and
|
||||
# patch match what Paperclip ships: packages/adapters/claude-local/package.json
|
||||
# pins 0.73.0 and applies patches/@agentclientprotocol__claude-agent-acp@0.73.0.patch
|
||||
@@ -46,6 +56,8 @@ USER 1000:1000
|
||||
# write only under $HOME, which the sandbox pod mounts writable. No ENV needed.
|
||||
|
||||
# Base user (1000:1000), entrypoint (tini) and cmd (agent shim) are inherited.
|
||||
RUN kubectl version --client && flux version --client && kustomize version && helm version \
|
||||
RUN TZ=Europe/Berlin date +%Z | grep -Eq '^CES?T$' \
|
||||
&& python3 -c "from zoneinfo import ZoneInfo; ZoneInfo('Europe/Berlin')" \
|
||||
&& kubectl version --client && flux version --client && kustomize version && helm version && jq --version \
|
||||
&& env -i PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
|
||||
sh -ec 'for t in kubectl flux kustomize helm claude-agent-acp; do command -v "$t"; done'
|
||||
sh -ec 'for t in kubectl flux kustomize helm jq claude-agent-acp; do command -v "$t"; done'
|
||||
|
||||
@@ -52,6 +52,14 @@ Job (and, on destroy, the pod) reads 404, polling for up to 20 s, below the
|
||||
server's 30 s plugin RPC timeout. If that check times out or fails, the hook
|
||||
returns no receipt, as before. No upstream PR yet.
|
||||
|
||||
Agents in k8s pods cannot read their own secrets (REN-1345): the sandbox
|
||||
callback bridge answers `403 Route not allowed` for `/api/agents/me/secrets`,
|
||||
although upstream's own Paperclip skill documents those routes for runs.
|
||||
`ren-1345-bridge-agent-secrets.patch` adds the five `/api/agents/me` secret
|
||||
routes (list, read one value, list/create/withdraw proposals) to the bridge
|
||||
allowlist. The server still resolves the agent from the run JWT and checks
|
||||
the binding; company-wide secret routes stay denied. No upstream PR yet.
|
||||
|
||||
## Files
|
||||
|
||||
- `upstream.env` — pinned commit and the version string stamped into the
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts
|
||||
index 4419bee..d57de8d 100644
|
||||
--- a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts
|
||||
+++ b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts
|
||||
@@ -1452,6 +1452,11 @@ describe("sandbox callback bridge", () => {
|
||||
{ method: "POST", path: "/api/routines/r-1/triggers" },
|
||||
{ method: "PATCH", path: "/api/routine-triggers/t-1" },
|
||||
{ method: "DELETE", path: "/api/routine-triggers/t-1" },
|
||||
+ { method: "GET", path: "/api/agents/me/secrets" },
|
||||
+ { method: "POST", path: "/api/agents/me/secrets/github_token/value" },
|
||||
+ { method: "GET", path: "/api/agents/me/secret-proposals" },
|
||||
+ { method: "POST", path: "/api/agents/me/secret-proposals" },
|
||||
+ { method: "DELETE", path: "/api/agents/me/secret-proposals/prop-1" },
|
||||
];
|
||||
for (const request of allowed) {
|
||||
expect(authorizeSandboxCallbackBridgeRequestWithRoutes(request)).toBeNull();
|
||||
@@ -1491,6 +1496,11 @@ describe("sandbox callback bridge", () => {
|
||||
{ method: "POST", path: "/api/companies/co-1/logo" },
|
||||
{ method: "GET", path: "/api/companies/co-1/secrets" },
|
||||
{ method: "PATCH", path: "/api/secrets/secret-1" },
|
||||
+ { method: "GET", path: "/api/agents/agent-1/secrets" },
|
||||
+ { method: "POST", path: "/api/agents/me/secrets/a/b/value" },
|
||||
+ { method: "GET", path: "/api/agents/me/secrets/github_token/value" },
|
||||
+ { method: "GET", path: "/api/companies/co-1/secret-proposals" },
|
||||
+ { method: "POST", path: "/api/companies/co-1/secret-proposals/prop-1/approve" },
|
||||
];
|
||||
for (const request of denied) {
|
||||
expect(authorizeSandboxCallbackBridgeRequestWithRoutes(request)).toBe(
|
||||
diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.ts b/packages/adapter-utils/src/sandbox-callback-bridge.ts
|
||||
index 40776a3..e825197 100644
|
||||
--- a/packages/adapter-utils/src/sandbox-callback-bridge.ts
|
||||
+++ b/packages/adapter-utils/src/sandbox-callback-bridge.ts
|
||||
@@ -133,6 +133,15 @@ export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST: readonly SandboxCa
|
||||
{ method: "POST", path: /^\/api\/agents\/[^/]+\/skills\/sync$/ },
|
||||
{ method: "PATCH", path: /^\/api\/agents\/[^/]+\/instructions-path$/ },
|
||||
|
||||
+ // Run-scoped secrets of the calling agent (`/agents/me` only). The server
|
||||
+ // resolves the agent from the run JWT and checks bindings; values are
|
||||
+ // registered for run-log redaction. Company-wide secret routes stay denied.
|
||||
+ { method: "GET", path: /^\/api\/agents\/me\/secrets$/ },
|
||||
+ { method: "POST", path: /^\/api\/agents\/me\/secrets\/[^/]+\/value$/ },
|
||||
+ { method: "GET", path: /^\/api\/agents\/me\/secret-proposals$/ },
|
||||
+ { method: "POST", path: /^\/api\/agents\/me\/secret-proposals$/ },
|
||||
+ { method: "DELETE", path: /^\/api\/agents\/me\/secret-proposals\/[^/]+$/ },
|
||||
+
|
||||
// Read-only schema discovery for validated control-plane requests.
|
||||
{ method: "GET", path: /^\/api\/openapi\.json$/ },
|
||||
|
||||
@@ -1591,7 +1591,7 @@ data:
|
||||
# return entries instead of short-circuiting. Built from
|
||||
# the samba-bridge repo at
|
||||
# gitea.renner.uno/k.renner/authentik-samba-bridge .
|
||||
container_image: gitea.renner.uno/k.renner/authentik-samba-bridge-ldap-outpost:2026.5.3-f02b945
|
||||
container_image: gitea.renner.uno/k.renner/authentik-samba-bridge-ldap-outpost:2026.8.3-872f754
|
||||
object_naming_template: "ak-outpost-%(name)s"
|
||||
kubernetes_namespace: authentik
|
||||
kubernetes_replicas: 1
|
||||
|
||||
@@ -20,7 +20,7 @@ spec:
|
||||
# Pinned to match the samba-bridge fork's app image (server.Dockerfile
|
||||
# AUTHENTIK_VERSION). Bump in lockstep with the bridge rebuild — a
|
||||
# floating range drifts the chart ahead of the pinned app image.
|
||||
version: "2026.5.3"
|
||||
version: "2026.8.3"
|
||||
sourceRef:
|
||||
kind: HelmRepository
|
||||
name: authentik
|
||||
@@ -58,7 +58,7 @@ spec:
|
||||
# 2. Pinning means the deployed binary is traceable to a
|
||||
# specific repo commit. Bump the tag whenever a new build
|
||||
# is published from gitea.renner.uno/k.renner/authentik-samba-bridge .
|
||||
tag: "2026.5.3-f02b945"
|
||||
tag: "2026.8.3-872f754"
|
||||
envFrom:
|
||||
# SAMBA_BRIDGE_DOMAIN_SID / _ENABLED_GROUP / _UID_OFFSET
|
||||
# consumed by the bridge module at Django app startup. Missing
|
||||
@@ -100,6 +100,13 @@ spec:
|
||||
secretKeyRef:
|
||||
name: ldap-outpost-tls
|
||||
key: tls.key
|
||||
# 2026.8 honours X-Forwarded-Proto/-Host/-For only from these
|
||||
# networks. Traefik reaches authentik-server (IPv4 single-stack
|
||||
# Service) from a pod IP; same ranges Traefik itself trusts for
|
||||
# forwarded headers. Without them, HTTPS is seen as HTTP and SSO
|
||||
# breaks. Comma-separated, no spaces (authentik list parsing).
|
||||
- name: AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS
|
||||
value: "10.244.0.0/16,fd10:244::/48,127.0.0.0/8,::1/128"
|
||||
|
||||
authentik:
|
||||
log_level: info
|
||||
|
||||
@@ -12,3 +12,10 @@ resources:
|
||||
- ingressroute-ssh.yaml
|
||||
- ingress.yaml
|
||||
- networkpolicy.yaml
|
||||
# Nightly prune of old CI image builds (REN-1203).
|
||||
- package-retention.yaml
|
||||
configMapGenerator:
|
||||
- name: gitea-package-retention
|
||||
namespace: gitea
|
||||
files:
|
||||
- package-retention.py
|
||||
|
||||
@@ -49,3 +49,26 @@ spec:
|
||||
- ports:
|
||||
- port: "3000"
|
||||
protocol: TCP
|
||||
---
|
||||
# Package retention CronJob (package-retention.yaml, REN-1203). It lists pods
|
||||
# to find the running image tags. The namespace baseline does not open the API
|
||||
# server: `kube-apiserver` is its own entity in Cilium (see
|
||||
# network-policies/paperclip-renner-ns.yaml); port is the post-DNAT 6443. Its
|
||||
# Gitea calls go to the in-cluster Service, which the baseline already allows
|
||||
# inside the namespace.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: gitea-package-retention-kube-apiserver
|
||||
namespace: gitea
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: gitea-package-retention
|
||||
egress:
|
||||
- toEntities:
|
||||
- kube-apiserver
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "6443"
|
||||
protocol: TCP
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Prune old CI image builds from Gitea's container registry (REN-1203).
|
||||
|
||||
See package-retention.yaml for what is pruned, what is kept, and why.
|
||||
Standard library only.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import ssl
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
|
||||
GITEA = os.environ["GITEA_URL"].rstrip("/")
|
||||
TOKEN = os.environ["GITEA_TOKEN"]
|
||||
REGISTRY_HOST = os.environ["REGISTRY_HOST"]
|
||||
OWNERS = os.environ["OWNERS"].split(",")
|
||||
KEEP = int(os.environ["KEEP_IMAGES"])
|
||||
GROUP_SECONDS = int(os.environ["GROUP_SECONDS"])
|
||||
MAX_DELETES = int(os.environ["MAX_DELETES"])
|
||||
EXCLUDE = re.compile(os.environ["EXCLUDE_PACKAGES"])
|
||||
DRY_RUN = os.environ.get("DRY_RUN", "false") == "true"
|
||||
|
||||
# A package is a CI build package if at least one of its tags looks like this.
|
||||
BUILD_TAG = re.compile(r"^(build\.[0-9]+|main-[0-9a-f]{7,40})$")
|
||||
ALWAYS_KEEP = {"main", "latest"}
|
||||
|
||||
SA = "/var/run/secrets/kubernetes.io/serviceaccount"
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(msg, flush=True)
|
||||
|
||||
|
||||
def gitea(method, path):
|
||||
req = urllib.request.Request(
|
||||
GITEA + path, method=method, headers={"Authorization": "token " + TOKEN}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
body = resp.read()
|
||||
return json.loads(body) if body else None
|
||||
|
||||
|
||||
def ts(s):
|
||||
# Gitea returns RFC 3339, e.g. 2026-10-07T18:07:32Z or +02:00.
|
||||
return datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp()
|
||||
|
||||
|
||||
def running_images():
|
||||
"""Tags in use by any pod, per package. Packages referenced by digest only
|
||||
cannot be matched to a tag, so they are returned in `unsafe` and skipped."""
|
||||
ctx = ssl.create_default_context(cafile=SA + "/ca.crt")
|
||||
with open(SA + "/token") as f:
|
||||
sa_token = f.read().strip()
|
||||
req = urllib.request.Request(
|
||||
"https://kubernetes.default.svc/api/v1/pods",
|
||||
headers={"Authorization": "Bearer " + sa_token},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=60, context=ctx) as resp:
|
||||
pods = json.load(resp)["items"]
|
||||
in_use, unsafe = {}, set()
|
||||
prefix = REGISTRY_HOST + "/"
|
||||
for pod in pods:
|
||||
spec = pod["spec"]
|
||||
for c in (
|
||||
spec.get("containers", [])
|
||||
+ spec.get("initContainers", [])
|
||||
+ spec.get("ephemeralContainers", [])
|
||||
):
|
||||
image = c["image"]
|
||||
if not image.startswith(prefix):
|
||||
continue
|
||||
ref = image[len(prefix):].split("@", 1)[0]
|
||||
repo, _, tag = ref.rpartition(":")
|
||||
if not repo or "/" in tag:
|
||||
unsafe.add(ref)
|
||||
continue
|
||||
in_use.setdefault(repo, set()).add(tag)
|
||||
return in_use, unsafe
|
||||
|
||||
|
||||
def list_packages(owner):
|
||||
out, page = [], 1
|
||||
while True:
|
||||
batch = gitea(
|
||||
"GET", f"/api/v1/packages/{owner}?type=container&limit=50&page={page}"
|
||||
)
|
||||
if not batch:
|
||||
return out
|
||||
out.extend(batch)
|
||||
page += 1
|
||||
|
||||
|
||||
def plan(versions, protected):
|
||||
"""Group tags into images (one push writes all tags of an image within a
|
||||
few seconds) and return the tags of the images to delete."""
|
||||
tags = sorted(
|
||||
(v for v in versions if not v["version"].startswith("sha256:")),
|
||||
key=lambda v: ts(v["created_at"]),
|
||||
reverse=True,
|
||||
)
|
||||
images, last = [], None
|
||||
for v in tags:
|
||||
t = ts(v["created_at"])
|
||||
if last is None or last - t > GROUP_SECONDS:
|
||||
images.append([])
|
||||
images[-1].append(v["version"])
|
||||
last = t
|
||||
delete = []
|
||||
for i, image in enumerate(images):
|
||||
if i < KEEP or set(image) & (ALWAYS_KEEP | protected):
|
||||
continue
|
||||
delete.extend(image)
|
||||
return images, delete
|
||||
|
||||
|
||||
def main():
|
||||
in_use, unsafe = running_images()
|
||||
log(f"running tags from {REGISTRY_HOST}: {sum(len(t) for t in in_use.values())}")
|
||||
work = []
|
||||
for owner in OWNERS:
|
||||
by_name = {}
|
||||
for v in list_packages(owner):
|
||||
by_name.setdefault(v["name"], []).append(v)
|
||||
for name, versions in sorted(by_name.items()):
|
||||
full = f"{owner}/{name}"
|
||||
if EXCLUDE.search(full):
|
||||
log(f"skip {full}: excluded")
|
||||
continue
|
||||
if not any(BUILD_TAG.match(v["version"]) for v in versions):
|
||||
continue
|
||||
if full in unsafe:
|
||||
log(f"skip {full}: a pod pins it by digest only")
|
||||
continue
|
||||
protected = in_use.get(full, set())
|
||||
images, delete = plan(versions, protected)
|
||||
log(f"{full}: {len(images)} images, {len(delete)} tags to delete, running {sorted(protected)}")
|
||||
work += [(owner, name, tag) for tag in delete]
|
||||
if len(work) > MAX_DELETES:
|
||||
log(f"abort: {len(work)} deletes exceed MAX_DELETES={MAX_DELETES}; nothing deleted")
|
||||
return 1
|
||||
failed = 0
|
||||
for owner, name, tag in work:
|
||||
if DRY_RUN:
|
||||
log(f"dry-run: would delete {owner}/{name}:{tag}")
|
||||
continue
|
||||
path = "/api/v1/packages/{}/container/{}/{}".format(
|
||||
owner, urllib.parse.quote(name, safe=""), urllib.parse.quote(tag, safe="")
|
||||
)
|
||||
try:
|
||||
gitea("DELETE", path)
|
||||
log(f"deleted {owner}/{name}:{tag}")
|
||||
except Exception as e: # keep going; report at the end
|
||||
failed += 1
|
||||
log(f"FAILED {owner}/{name}:{tag}: {e}")
|
||||
log(f"done: {len(work) - failed} deleted, {failed} failed, dry_run={DRY_RUN}")
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,165 @@
|
||||
# Nightly retention for CI image builds in Gitea's container registry (REN-1203).
|
||||
#
|
||||
# Why: nothing pruned old builds, so the data volume filled up (REN-580, at
|
||||
# 40Gi) and was down to 24% free again four days after the grow to 60Gi
|
||||
# (REN-1203, about 2.2 GB/day). Gitea 1.21 has no API for per-owner cleanup
|
||||
# rules, and the owners are human accounts, so the rules cannot live in Gitea.
|
||||
# This job applies the rule Hermes ran by hand in REN-1204.
|
||||
#
|
||||
# What it prunes, per package of the owners in OWNERS:
|
||||
# - only CI build packages: at least one tag is `build.<n>` or `main-<sha>`.
|
||||
# Date-tagged utility images (alpine-jq, cni-plugins, ...) are not touched.
|
||||
# - one image carries several tags (build.N, short and long sha, sometimes
|
||||
# main/latest). Tags pushed within GROUP_SECONDS are one image, and the job
|
||||
# deletes all tags of an image, because a sha tag left behind keeps the
|
||||
# blobs alive.
|
||||
# - keeps the KEEP_IMAGES newest images, any image tagged main or latest, and
|
||||
# any image whose tag a pod in the cluster runs now. A package that a pod
|
||||
# pins by digest only is skipped as a whole.
|
||||
# - never touches gitea_admin/* (CI base images) or EXCLUDE_PACKAGES. The
|
||||
# agent-runtime images are excluded because Paperclip pins their tag in its
|
||||
# own config, not in a pod that exists between runs.
|
||||
# - aborts without deleting if the plan exceeds MAX_DELETES.
|
||||
# Freed blobs are removed by Gitea's own `cleanup_packages` cron (daily).
|
||||
#
|
||||
# Token: `gitea-hermes-bot-package-retention-token` on hermes-bot (site admin),
|
||||
# scope `write:package` only (REN-1204). Admin and repo routes return 403 with
|
||||
# it. Hermes owns the account and the token.
|
||||
#
|
||||
# Kubernetes access: list pods, cluster-wide, to find the running tags. Nothing
|
||||
# else.
|
||||
#
|
||||
# Dry run: set DRY_RUN to "true"; the job then only logs the plan.
|
||||
# Stop it: `spec.suspend: true` in git. Revert: delete this file, the
|
||||
# configMapGenerator entry and the policy in networkpolicy.yaml. Deleted images
|
||||
# cannot be restored; CI rebuilds one from its commit.
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: gitea-package-retention
|
||||
namespace: gitea
|
||||
spec:
|
||||
refreshInterval: 1h
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: gitea-package-retention
|
||||
data:
|
||||
- secretKey: GITEA_TOKEN
|
||||
remoteRef:
|
||||
key: infrastructure/gitea/hermes-bot
|
||||
property: gitea-hermes-bot-package-retention-token
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: gitea-package-retention
|
||||
namespace: gitea
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: gitea-package-retention
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["pods"]
|
||||
verbs: ["list"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: gitea-package-retention
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: gitea-package-retention
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: gitea-package-retention
|
||||
namespace: gitea
|
||||
---
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: gitea-package-retention
|
||||
namespace: gitea
|
||||
labels:
|
||||
app.kubernetes.io/name: gitea-package-retention
|
||||
spec:
|
||||
# Quiet hour for CI pushes. Gitea's own daily cleanup_packages cron frees
|
||||
# the blobs afterwards.
|
||||
schedule: "30 22 * * *"
|
||||
timeZone: Etc/UTC
|
||||
concurrencyPolicy: Forbid
|
||||
successfulJobsHistoryLimit: 2
|
||||
failedJobsHistoryLimit: 3
|
||||
startingDeadlineSeconds: 3600
|
||||
jobTemplate:
|
||||
spec:
|
||||
backoffLimit: 0
|
||||
activeDeadlineSeconds: 1800
|
||||
ttlSecondsAfterFinished: 604800
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: gitea-package-retention
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
enableServiceLinks: false
|
||||
serviceAccountName: gitea-package-retention
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65534
|
||||
runAsGroup: 65534
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: retention
|
||||
image: docker.io/library/python:3.13-alpine@sha256:2d9aefe2fef018a7eb2c13064c89c71929800fd2e5dccdbf52ea5da5bb8d929a
|
||||
command: ["python3", "/app/package-retention.py"]
|
||||
env:
|
||||
# In-cluster Service; the public name hairpins off the ingress
|
||||
# LB from a pod (see networkpolicy.yaml).
|
||||
- name: GITEA_URL
|
||||
value: http://gitea.gitea.svc:3000
|
||||
- name: REGISTRY_HOST
|
||||
value: gitea.renner.uno
|
||||
- name: OWNERS
|
||||
value: k.renner,konairius
|
||||
- name: KEEP_IMAGES
|
||||
value: "5"
|
||||
# Tags of one push land within seconds; the smallest gap
|
||||
# between two images was 379 s (REN-1204).
|
||||
- name: GROUP_SECONDS
|
||||
value: "60"
|
||||
- name: MAX_DELETES
|
||||
value: "300"
|
||||
- name: EXCLUDE_PACKAGES
|
||||
value: "^konairius/agent-runtime"
|
||||
- name: DRY_RUN
|
||||
value: "false"
|
||||
- name: PYTHONDONTWRITEBYTECODE
|
||||
value: "1"
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: gitea-package-retention
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 32Mi
|
||||
limits:
|
||||
memory: 128Mi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumeMounts:
|
||||
- name: script
|
||||
mountPath: /app
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: script
|
||||
configMap:
|
||||
name: gitea-package-retention
|
||||
@@ -37,7 +37,7 @@ spec:
|
||||
# both handled as explicit steps in MIGRATION-gitea-woodpecker.md. Pinned
|
||||
# to match the LXC for a clean SQLite lift; no imagepolicy marker (an
|
||||
# auto-bump across versions on live SQLite data must never be applied).
|
||||
image: gitea/gitea:1.21.4-rootless
|
||||
image: gitea/gitea:1.21.4-rootless@sha256:600f1f1e1d090c763866db16882b5eac5c6a80b7664ee53bec0806c656c0033f
|
||||
env:
|
||||
- name: GITEA_WORK_DIR
|
||||
value: /var/lib/gitea
|
||||
|
||||
@@ -95,7 +95,7 @@ spec:
|
||||
memory: 32Mi
|
||||
containers:
|
||||
- name: home-assistant
|
||||
image: ghcr.io/home-assistant/home-assistant:2026.9.4 # {"$imagepolicy": "flux-system:home-assistant"}
|
||||
image: ghcr.io/home-assistant/home-assistant:2026.10.0 # {"$imagepolicy": "flux-system:home-assistant"}
|
||||
env:
|
||||
- name: TZ
|
||||
value: Europe/Berlin
|
||||
@@ -187,6 +187,55 @@ spec:
|
||||
memory: 32Mi
|
||||
limits:
|
||||
memory: 64Mi
|
||||
# MQTT client certificate for Home Assistant (REN-953). HA keeps
|
||||
# client certificates only as PEMs uploaded into its MQTT config
|
||||
# entry, so a cert-manager renewal (~60 days) could never reach it
|
||||
# without a reconfigure flow and an admin token. Instead HA's config
|
||||
# entry points at 127.0.0.1:11883 (plain, loopback only) and this
|
||||
# sidecar forwards to the broker's mTLS listener :8884 with the
|
||||
# certificate of the register account `home-assistant`. The broker
|
||||
# takes the username from the certificate CN; whatever user/password
|
||||
# HA sends is ignored. --timed-reload picks up a renewed certificate
|
||||
# for new connections without a restart; an open connection stays up
|
||||
# (checked locally with ghostunnel v1.11.3 and mosquitto 2.0.22).
|
||||
#
|
||||
# Probes: liveness only, on the HTTPS status port (process alive). No
|
||||
# readiness probe on purpose: a broker outage must not take HA out of
|
||||
# its Service endpoints.
|
||||
- name: mqtt-tls
|
||||
image: ghostunnel/ghostunnel:v1.11.3@sha256:2599b8a04bae16d70a4209495618dea61b46a00a9a05fcf74da282688ad3517f
|
||||
args:
|
||||
- client
|
||||
- --listen=127.0.0.1:11883
|
||||
- --target=mosquitto.home-assistant.svc.cluster.local:8884
|
||||
- --cert=/mqtt-tls/tls.crt
|
||||
- --key=/mqtt-tls/tls.key
|
||||
- --cacert=/mqtt-tls/ca.crt
|
||||
- --timed-reload=10m
|
||||
- --status=0.0.0.0:16060
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65534
|
||||
runAsGroup: 65534
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: [ALL]
|
||||
livenessProbe:
|
||||
tcpSocket:
|
||||
port: 16060
|
||||
periodSeconds: 30
|
||||
failureThreshold: 4
|
||||
volumeMounts:
|
||||
- name: mqtt-tls
|
||||
mountPath: /mqtt-tls
|
||||
readOnly: true
|
||||
resources:
|
||||
requests:
|
||||
cpu: 5m
|
||||
memory: 16Mi
|
||||
limits:
|
||||
memory: 64Mi
|
||||
# Browser-based VS Code editing /config in-place. Sidecar (not
|
||||
# separate Deployment) because the config PVC is RWO ceph-block.
|
||||
# Exposed LAN-only on vscode.home.renner.uno; --auth=none means
|
||||
@@ -253,7 +302,7 @@ spec:
|
||||
# in the PVC's remote URL; reload auth uses a dedicated long-lived token.
|
||||
# Runs as root to match HA's root-owned /config files. See config-sync.sh.
|
||||
- name: config-sync
|
||||
image: alpine/git:2.54.0
|
||||
image: alpine/git:2.54.0@sha256:a4bb51f1a3553df194ce679fc1db721d8bfba2046fa1a88fe9d4ac551ffbce25
|
||||
command: ["/bin/sh", "/scripts/sync.sh"]
|
||||
env:
|
||||
- name: SYNC_BRANCH
|
||||
@@ -297,6 +346,14 @@ spec:
|
||||
- name: proxy-config
|
||||
configMap:
|
||||
name: ha-proxy-config
|
||||
# Client certificate of the register account home-assistant, issued
|
||||
# by mqtt-account-controller. Not subPath, so renewals land in place.
|
||||
# 0444 because the sidecar runs as 65534; only this pod mounts it.
|
||||
# Not in the Reloader annotation: a renewal must not restart HA.
|
||||
- name: mqtt-tls
|
||||
secret:
|
||||
secretName: mqtt-home-assistant-tls
|
||||
defaultMode: 0444
|
||||
# Writable emptyDir for the nginx entrypoint's envsubst output
|
||||
# (/etc/nginx/conf.d/default.conf rendered from the .template).
|
||||
- name: proxy-conf-d
|
||||
|
||||
@@ -16,9 +16,6 @@ resources:
|
||||
# probes use the cert account mosquitto-probe, REN-953); removed with its
|
||||
# rotator in the REN-802 cleanup step.
|
||||
- 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
|
||||
|
||||
@@ -177,77 +177,6 @@ spec:
|
||||
limits:
|
||||
memory: 64Mi
|
||||
---
|
||||
# Second broker user: `shellies`. Separate Secret (not a second key on
|
||||
# mosquitto-credentials) so each credential's lifecycle is independent —
|
||||
# rotating one never touches the other, and existing clusters whose
|
||||
# mosquitto-credentials was already generated for `homeassistant` only
|
||||
# don't end up with a half-populated Secret.
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: mosquitto-shellies-credentials-bootstrap
|
||||
namespace: home-assistant
|
||||
annotations:
|
||||
# Job pod templates are immutable; force-recreate so image bumps
|
||||
# apply on Flux re-reconcile. Safe: the Job is idempotent (see above).
|
||||
kustomize.toolkit.fluxcd.io/force: enabled
|
||||
spec:
|
||||
backoffLimit: 4
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: mosquitto-bootstrap
|
||||
spec:
|
||||
serviceAccountName: mosquitto-bootstrap
|
||||
restartPolicy: OnFailure
|
||||
containers:
|
||||
- name: bootstrap
|
||||
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
set -eu
|
||||
apk add --no-cache curl jq >/dev/null
|
||||
|
||||
NS=home-assistant
|
||||
NAME=mosquitto-shellies-credentials
|
||||
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
|
||||
CA=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
|
||||
API=https://kubernetes.default.svc
|
||||
|
||||
if curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
|
||||
"$API/api/v1/namespaces/$NS/secrets/$NAME" >/dev/null 2>&1; then
|
||||
echo "$NAME already exists; nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PW=$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c 32)
|
||||
PW_B64=$(printf '%s' "$PW" | base64 | tr -d '\n')
|
||||
|
||||
body=$(jq -nc \
|
||||
--arg name "$NAME" \
|
||||
--arg ns "$NS" \
|
||||
--arg pw "$PW_B64" \
|
||||
'{apiVersion: "v1",
|
||||
kind: "Secret",
|
||||
metadata: {name: $name, namespace: $ns},
|
||||
type: "Opaque",
|
||||
data: {password: $pw}}')
|
||||
|
||||
curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" -X POST \
|
||||
--data "$body" \
|
||||
"$API/api/v1/namespaces/$NS/secrets" >/dev/null
|
||||
|
||||
echo "generated $NAME"
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 32Mi
|
||||
limits:
|
||||
memory: 64Mi
|
||||
---
|
||||
# Fourth broker user: `sml2mqtt` — the power-meter bridge. Publish-only
|
||||
# under `sml2mqtt/#` (ACL in mosquitto-deployment.yaml). Own credential so
|
||||
# its lifecycle is independent of the others. The sml2mqtt pod reads this
|
||||
@@ -392,7 +321,7 @@ spec:
|
||||
---
|
||||
# Broker user `zigbee2mqtt` — the Zigbee2MQTT bridge (ZHA replacement). Scoped
|
||||
# to its own `zigbee2mqtt/#` subtree plus `write homeassistant/#` (so it can
|
||||
# publish HA MQTT-discovery configs, exactly like the `shellies` user) and
|
||||
# publish HA MQTT-discovery configs) and
|
||||
# `read homeassistant/status` (HA's birth/will topic; on `online` Z2M
|
||||
# republishes cached device states, NOT discovery configs — ACL and the full
|
||||
# reasoning in mosquitto-deployment.yaml). Own credential so its lifecycle is
|
||||
|
||||
@@ -5,14 +5,6 @@ metadata:
|
||||
namespace: home-assistant
|
||||
labels:
|
||||
app.kubernetes.io/name: mosquitto
|
||||
annotations:
|
||||
# REQUIRED for credential rotation to take effect. The startup shim rebuilds
|
||||
# the password file only on pod start, so without this a rotated Secret sits
|
||||
# inert while the old password stays valid indefinitely.
|
||||
#
|
||||
# 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-open3e-int-credentials
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
@@ -74,23 +66,14 @@ spec:
|
||||
set -eu
|
||||
rm -f /mosquitto/auth/passwd
|
||||
mosquitto_passwd -c -b /mosquitto/auth/passwd homeassistant "$HA_PASSWORD"
|
||||
mosquitto_passwd -b /mosquitto/auth/passwd shellies "$SHELLY_PASSWORD"
|
||||
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 zigbee2mqtt "$ZIGBEE2MQTT_PASSWORD"
|
||||
# ACL: HA keeps full access (subscribes to everything); the
|
||||
# shellies user is `readwrite shellies/#` (devices publish state
|
||||
# and subscribe to their command topics) + `write homeassistant/#`
|
||||
# so Shelly Gen2 firmwares can publish HA MQTT-discovery configs.
|
||||
# It is WRITE-only on homeassistant/# on purpose: a publish-only
|
||||
# client never needs to read the whole discovery/state tree, and
|
||||
# denying read removes device-inventory recon for a compromised
|
||||
# Shelly. (Full per-device write scoping isn't expressible with
|
||||
# the flat discovery topic layout; that would need a dynamic-auth
|
||||
# plugin — out of scope.)
|
||||
# ACL: HA keeps full access (subscribes to everything). Shellies
|
||||
# are not legacy users any more: each has its own Dynamic Security
|
||||
# account from mqtt-accounts.yaml (REN-802, REN-1017).
|
||||
# `admin` is a workstation/debugging credential — full
|
||||
# readwrite, kept on its own credential so its password can
|
||||
# be rotated independently of the HA session.
|
||||
@@ -111,10 +94,6 @@ spec:
|
||||
user homeassistant
|
||||
topic readwrite #
|
||||
|
||||
user shellies
|
||||
topic readwrite shellies/#
|
||||
topic write homeassistant/#
|
||||
|
||||
user admin
|
||||
topic readwrite #
|
||||
|
||||
@@ -124,12 +103,6 @@ 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/#
|
||||
@@ -137,9 +110,8 @@ spec:
|
||||
|
||||
# `zigbee2mqtt` is the Zigbee2MQTT bridge (the ZHA replacement).
|
||||
# Owns its own `zigbee2mqtt/#` subtree; may WRITE `homeassistant/#`
|
||||
# to publish HA MQTT-discovery configs (write-only there, like the
|
||||
# shellies user, to deny discovery-tree recon from a compromised
|
||||
# bridge); and READs `homeassistant/status`, HA's birth/will topic.
|
||||
# to publish HA MQTT-discovery configs (write-only there, to deny
|
||||
# discovery-tree recon from a compromised bridge); and READs `homeassistant/status`, HA's birth/will topic.
|
||||
#
|
||||
# That read grant does NOT buy a discovery re-publish: on
|
||||
# `online`, Z2M 2.12.1 republishes bridge/state and cached device
|
||||
@@ -176,11 +148,6 @@ spec:
|
||||
secretKeyRef:
|
||||
name: mosquitto-credentials
|
||||
key: homeassistant-password
|
||||
- name: SHELLY_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: mosquitto-shellies-credentials
|
||||
key: password
|
||||
- name: ADMIN_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
@@ -196,13 +163,6 @@ 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:
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# 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
|
||||
@@ -47,8 +47,8 @@ subjects:
|
||||
---
|
||||
# Per deliver.namespace: manage the account Certificates there (server-side
|
||||
# apply = patch, which creates through `create`). One Role + RoleBinding per
|
||||
# namespace that appears as `deliver.namespace` in the register: home-assistant
|
||||
# and evcc.
|
||||
# namespace that appears as `deliver.namespace` in the register: home-assistant,
|
||||
# evcc and paperclip.
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
@@ -85,6 +85,8 @@ subjects:
|
||||
# that creates, because the name is in the request path).
|
||||
# mqtt-<name>-tls: delete on revocation, one entry per cert account
|
||||
# delivered to this namespace. Add the name with the register entry.
|
||||
# mqtt-ha-cert-sync: sha256 of the client certificate last accepted by
|
||||
# Home Assistant's MQTT config entry (HA cert sync, REN-953/REN-1284).
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
@@ -95,7 +97,7 @@ metadata:
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
resourceNames: ["mqtt-account-hashes", "mqtt-dynsec-seed"]
|
||||
resourceNames: ["mqtt-account-hashes", "mqtt-dynsec-seed", "mqtt-ha-cert-sync"]
|
||||
verbs: ["get", "create", "patch"]
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
@@ -103,6 +105,7 @@ rules:
|
||||
- mqtt-test-cert-tls
|
||||
- mqtt-mosquitto-probe-tls
|
||||
- mqtt-zigbee2mqtt-bridge-tls
|
||||
- mqtt-home-assistant-tls
|
||||
verbs: ["delete"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
@@ -156,6 +159,41 @@ subjects:
|
||||
name: mqtt-account-controller
|
||||
namespace: home-assistant
|
||||
---
|
||||
# deliver.namespace paperclip (account agents-read): its Certificate, and its
|
||||
# Secret by name for revocation.
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: mqtt-account-controller-certificates
|
||||
namespace: paperclip
|
||||
labels:
|
||||
app.kubernetes.io/name: mqtt-account-controller
|
||||
rules:
|
||||
- apiGroups: ["cert-manager.io"]
|
||||
resources: ["certificates"]
|
||||
verbs: ["get", "create", "patch", "delete"]
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
resourceNames:
|
||||
- mqtt-agents-read-tls
|
||||
verbs: ["delete"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: mqtt-account-controller-certificates
|
||||
namespace: paperclip
|
||||
labels:
|
||||
app.kubernetes.io/name: mqtt-account-controller
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: Role
|
||||
name: mqtt-account-controller-certificates
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: mqtt-account-controller
|
||||
namespace: home-assistant
|
||||
---
|
||||
# The controller's own MQTT identity: CN = Dynamic Security admin client
|
||||
# (ADMIN_USER). Not labelled managed-by, so the controller never treats it
|
||||
# as an account certificate and never revokes it. Re-read on every TLS
|
||||
@@ -187,6 +225,10 @@ metadata:
|
||||
namespace: home-assistant
|
||||
labels:
|
||||
app.kubernetes.io/name: mqtt-account-controller
|
||||
annotations:
|
||||
# The controller reads the shelly-manager API key once at start; the
|
||||
# quarterly rotation of shelly-manager-auth must restart it too.
|
||||
secret.reloader.stakater.com/reload: shelly-manager-auth
|
||||
spec:
|
||||
replicas: 1
|
||||
# One writer at a time: two controllers would race on rotations and the
|
||||
@@ -212,10 +254,33 @@ spec:
|
||||
- name: controller
|
||||
# Woodpecker builds main-<sha> on every merge to main; no sortable
|
||||
# tag, so bumps are manual (same as ical-proxy).
|
||||
image: gitea.renner.uno/konairius/mqtt-account-controller:main-b3d5cbb
|
||||
image: gitea.renner.uno/konairius/mqtt-account-controller:main-cb96b1a@sha256:9d9b9e83a9c0f9f567e57edacab11cf4c58e8c0e49e38e8d7b51421cd1242911
|
||||
env:
|
||||
- name: BROKER_URL
|
||||
value: ssl://mosquitto.home-assistant.svc.cluster.local:8884
|
||||
# Password push to Shellies (accounts with deliver.shelly-manager).
|
||||
# The API-key-only Service (api-proxy sidecar, :8081), like the
|
||||
# agents; same namespace, so the default-deny allows it.
|
||||
- name: SHELLY_MANAGER_URL
|
||||
value: http://shelly-manager-api.home-assistant.svc.cluster.local
|
||||
- name: SHELLY_MANAGER_API_KEY_FILE
|
||||
value: /etc/shelly-manager/api-key
|
||||
# HA cert sync (REN-953, REN-1284): keeps Home Assistant's MQTT
|
||||
# config entry on mosquitto:8884 with the CA and client
|
||||
# certificate of the register account `home-assistant`. After
|
||||
# each loop the controller compares tls.crt (mounted below) with
|
||||
# the last certificate HA accepted (Secret mqtt-ha-cert-sync) and,
|
||||
# only inside 02:00-05:00 Europe/Berlin, runs HA's reconfigure
|
||||
# flow. HA tests the connection before it saves. Defaults:
|
||||
# broker mosquitto.home-assistant.svc.cluster.local:8884, cert dir
|
||||
# /etc/mqtt-home-assistant-tls. Off while HA_MQTT_ENTRY_ID is unset.
|
||||
- name: HA_MQTT_ENTRY_ID
|
||||
value: 01KRFXD4046FEBWQ0EX0GNE418
|
||||
# HA itself (:8123), not the :8124 XFF proxy; same namespace.
|
||||
- name: HA_URL
|
||||
value: http://home-assistant-bgp.home-assistant.svc.cluster.local:8123
|
||||
- name: HA_TOKEN_FILE
|
||||
value: /etc/ha-token/token
|
||||
# Not :8080: several :8080 ingress grants in the home-assistant
|
||||
# default-deny apply to every pod in the namespace.
|
||||
- name: LISTEN
|
||||
@@ -243,6 +308,15 @@ spec:
|
||||
- name: tls
|
||||
mountPath: /etc/mqtt-tls
|
||||
readOnly: true
|
||||
- name: shelly-manager-key
|
||||
mountPath: /etc/shelly-manager
|
||||
readOnly: true
|
||||
- name: ha-mqtt-tls
|
||||
mountPath: /etc/mqtt-home-assistant-tls
|
||||
readOnly: true
|
||||
- name: ha-token
|
||||
mountPath: /etc/ha-token
|
||||
readOnly: true
|
||||
resources:
|
||||
requests:
|
||||
cpu: 5m
|
||||
@@ -258,3 +332,42 @@ spec:
|
||||
- name: tls
|
||||
secret:
|
||||
secretName: mqtt-account-controller-tls
|
||||
# Only the api-key item, not the shelly-manager admin password.
|
||||
- name: shelly-manager-key
|
||||
secret:
|
||||
secretName: shelly-manager-auth
|
||||
items:
|
||||
- key: api-key
|
||||
path: api-key
|
||||
# Client certificate HA gets (cert-manager renews it in place; not a
|
||||
# subPath mount, so the next loop sees the new file).
|
||||
- name: ha-mqtt-tls
|
||||
secret:
|
||||
secretName: mqtt-home-assistant-tls
|
||||
# HA long-lived token of the service user `MQTT Cert Sync`
|
||||
# (system-admin; config flows need admin). REN-1268.
|
||||
- name: ha-token
|
||||
secret:
|
||||
secretName: mqtt-account-controller-ha-token
|
||||
---
|
||||
# HA admin token for the HA cert sync (see the Deployment env). Minted by
|
||||
# Hermes on a dedicated HA service user, not the owner's account (REN-1268).
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: mqtt-account-controller-ha-token
|
||||
namespace: home-assistant
|
||||
labels:
|
||||
app.kubernetes.io/name: mqtt-account-controller
|
||||
spec:
|
||||
refreshInterval: 1h
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: mqtt-account-controller-ha-token
|
||||
data:
|
||||
- secretKey: token
|
||||
remoteRef:
|
||||
key: apps/home-assistant/mqtt-cert-sync-token
|
||||
property: token
|
||||
|
||||
@@ -41,3 +41,184 @@
|
||||
auth: cert
|
||||
deliver: {namespace: evcc}
|
||||
acl: ["read open3e/#", "readwrite evcc/#", "read energy/#"]
|
||||
# Home Assistant, via the ghostunnel sidecar `mqtt-tls` in its pod (HA keeps
|
||||
# client certificates only as uploaded PEMs in its config entry, so a renewal
|
||||
# could not reach it without a reconfigure). Same ACL as the legacy user
|
||||
# `homeassistant`; not named like it, so it cannot shadow that login (REN-953).
|
||||
- name: home-assistant
|
||||
auth: cert
|
||||
deliver: {namespace: home-assistant}
|
||||
acl: ["readwrite #"]
|
||||
# Read-only account for agents (REN-953). One account for all of them: the
|
||||
# agents share sandboxes, so per-agent separation would be nominal. The
|
||||
# Secret lands in `paperclip`; agents fetch it per run with their kubectl
|
||||
# identity (Role mqtt-agents-read in apps/paperclip/agent-kubectl-rbac.yaml),
|
||||
# so a renewal needs no restart. Network path: agent sandbox pods ->
|
||||
# mosquitto :8884 only.
|
||||
- name: agents-read
|
||||
auth: cert
|
||||
deliver: {namespace: paperclip}
|
||||
acl: ["read open3e/#", "read zigbee2mqtt/#", "read homeassistant/#"]
|
||||
# edge-1's local mosquitto bridge (REN-1307): one bridge for open3e/e3oncan
|
||||
# and sml2mqtt, replacing the legacy password users `open3e` and `sml2mqtt`
|
||||
# (different name, so it cannot shadow their logins). The controller pushes
|
||||
# the password to edge-mqtt-cred on edge-1 (ansible role mqtt-bridge) over
|
||||
# HTTPS with its own client certificate, and pins edge-1's self-generated
|
||||
# server certificate on first contact (key edge-1.pin in mqtt-account-hashes;
|
||||
# delete it after a reinstall of edge-1).
|
||||
- name: edge-1
|
||||
auth: password
|
||||
deliver: {esp32: "https://edge-1.iot.renner.uno:8443"}
|
||||
# No sml2mqtt/#: the SML meter reader is retired for good (owner,
|
||||
# 2026-09-19; grid data comes from the E380 on the CAN bus).
|
||||
acl: ["readwrite open3e/#"]
|
||||
# Shellies (REN-1017): one password account per device, pushed by the
|
||||
# controller through shelly-manager's per-device API. Name shelly-<id> =
|
||||
# shelly-manager device id. ACL as the former shared user `shellies`
|
||||
# (removed in REN-1017 step 4). Gen2+ devices reboot once when their password
|
||||
# is pushed. A new Shelly needs its entry here first: shelly-manager's MQTT
|
||||
# template user no longer exists on the broker.
|
||||
- name: shelly-5 # Plus i4, the API test device of REN-929
|
||||
auth: password
|
||||
deliver: {shelly-manager: 5}
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
# Only devices that log in as `shellies` today. A device with MQTT off never
|
||||
# reports connected, so the controller would push (and reboot it) again
|
||||
# every ROTATE_GRACE.
|
||||
- name: shelly-6
|
||||
auth: password
|
||||
deliver: {shelly-manager: 6} # Plus i4 90380c3808b8
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-29
|
||||
auth: password
|
||||
deliver: {shelly-manager: 29} # Plus i4 b8d61a8c7f24
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-3
|
||||
auth: password
|
||||
deliver: {shelly-manager: 3} # Pro 3EM ac15186d43c0
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-42
|
||||
auth: password
|
||||
deliver: {shelly-manager: 42} # 3EM-63 G3 d885ac1c5ab4
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-43
|
||||
auth: password
|
||||
deliver: {shelly-manager: 43} # 3EM-63 G3 d885ac1ab274
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-10
|
||||
auth: password
|
||||
deliver: {shelly-manager: 10} # Plug S Gen1 4382e5
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-22
|
||||
auth: password
|
||||
deliver: {shelly-manager: 22} # Plug S Gen1 87e961
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-25
|
||||
auth: password
|
||||
deliver: {shelly-manager: 25} # Plug S Gen1 1c1a8f
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-15
|
||||
auth: password
|
||||
deliver: {shelly-manager: 15} # Plug S G3 8cbfea910578
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-16
|
||||
auth: password
|
||||
deliver: {shelly-manager: 16} # Plug S G3 b08184a63ef0
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-14
|
||||
auth: password
|
||||
deliver: {shelly-manager: 14} # Plus 1PM Mini 348518e0be40
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-27
|
||||
auth: password
|
||||
deliver: {shelly-manager: 27} # Plus 1PM Mini 348518e06c04
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-13
|
||||
auth: password
|
||||
deliver: {shelly-manager: 13} # Plus 2PM 48551997e624
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-19
|
||||
auth: password
|
||||
deliver: {shelly-manager: 19} # Plus 2PM 48551999a790
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-21
|
||||
auth: password
|
||||
deliver: {shelly-manager: 21} # Plus 2PM 0cdc7efa8d00
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-34
|
||||
auth: password
|
||||
deliver: {shelly-manager: 34} # Plus 2PM 3ce90e2f2d7c
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-2
|
||||
auth: password
|
||||
deliver: {shelly-manager: 2} # Wall Display 00082266e106
|
||||
# Checked but never rotated on "device not connected" (REN-1066).
|
||||
rotate: false
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-26
|
||||
auth: password
|
||||
deliver: {shelly-manager: 26} # Power Strip G4 206ef102a6cc
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-1
|
||||
auth: password
|
||||
deliver: {shelly-manager: 1} # Pro 4PM 9454c5b9c954
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-4
|
||||
auth: password
|
||||
deliver: {shelly-manager: 4} # Pro 4PM ac15186ca578
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-32
|
||||
auth: password
|
||||
deliver: {shelly-manager: 32} # Pro 4PM 30c6f7834ec4
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-33
|
||||
auth: password
|
||||
deliver: {shelly-manager: 33} # Pro 3 fce8c0db5f28
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
# HiluX DS8: HA drives them over MQTT only, and their firmware can hang in
|
||||
# the MQTT reconnect path until a power cycle (wiki
|
||||
# shelly-hilux-ds8-mqtt-reconnect-hang-bug). One unit at a time; 12 is a
|
||||
# bathroom mesh receiver, so its wall switch works without MQTT.
|
||||
- name: shelly-12
|
||||
auth: password
|
||||
deliver: {shelly-manager: 12} # HiluX DS8 34b7da890cfc, Bad Spiegel Rechts
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-9
|
||||
auth: password
|
||||
deliver: {shelly-manager: 9} # HiluX DS8 34b7da8c03d0, Bad Badewanne
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-31
|
||||
auth: password
|
||||
deliver: {shelly-manager: 31} # HiluX DS8 34b7da888050, Bad Spiegel Links
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-44
|
||||
auth: password
|
||||
deliver: {shelly-manager: 44} # HiluX DS8 34b7da894604, Toilette (standalone, no mesh)
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-30
|
||||
auth: password
|
||||
deliver: {shelly-manager: 30} # Plus 2PM b48a0a226c28 (reachable again, REN-1108)
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
# Sleeping H&Ts (REN-1107): awake only 8-13 s every 1-2 h, so the
|
||||
# controller pushes when the device publishes `online` on its wake topic
|
||||
# (shelly-manager topic prefix `shellies/<hostname>`), not in the 2-min loop.
|
||||
- name: shelly-35
|
||||
auth: password
|
||||
deliver: {shelly-manager: 35} # Plus H&T 08b61fcea690
|
||||
wake: shellies/shellyplusht-08b61fcea690/online
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-36
|
||||
auth: password
|
||||
deliver: {shelly-manager: 36} # Plus H&T 08b61fce90dc
|
||||
wake: shellies/shellyplusht-08b61fce90dc/online
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-37
|
||||
auth: password
|
||||
deliver: {shelly-manager: 37} # H&T G3 84fce63ab4d8
|
||||
wake: shellies/shellyhtg3-84fce63ab4d8/online
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-38
|
||||
auth: password
|
||||
deliver: {shelly-manager: 38} # H&T G3 ecda3bc0b6a0
|
||||
wake: shellies/shellyhtg3-ecda3bc0b6a0/online
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
|
||||
@@ -61,7 +61,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: shelly-manager
|
||||
image: gitea.renner.uno/k.renner/shelly-manager:build.1791337222 # {"$imagepolicy": "flux-system:shelly-manager"}
|
||||
image: gitea.renner.uno/k.renner/shelly-manager:build.1791426752 # {"$imagepolicy": "flux-system:shelly-manager"}
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: http
|
||||
|
||||
@@ -32,7 +32,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: immich-machine-learning
|
||||
image: ghcr.io/immich-app/immich-machine-learning:v3.2.4 # {"$imagepolicy": "flux-system:immich-machine-learning"}
|
||||
image: ghcr.io/immich-app/immich-machine-learning:v3.3.0 # {"$imagepolicy": "flux-system:immich-machine-learning"}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3003
|
||||
|
||||
@@ -87,7 +87,7 @@ spec:
|
||||
mountPath: /ca
|
||||
containers:
|
||||
- name: immich-server
|
||||
image: ghcr.io/immich-app/immich-server:v3.2.4 # {"$imagepolicy": "flux-system:immich-server"}
|
||||
image: ghcr.io/immich-app/immich-server:v3.3.0 # {"$imagepolicy": "flux-system:immich-server"}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3001
|
||||
|
||||
@@ -54,7 +54,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: llama-server
|
||||
image: ghcr.io/ggml-org/llama.cpp:server-vulkan-b10731
|
||||
image: ghcr.io/ggml-org/llama.cpp:server-vulkan-b10731@sha256:0262343f64f030e8efff15ec05ee8180b69b7f6745de2caa3af5148e0dcb8aba
|
||||
args:
|
||||
# The one place this model is named; llama-server resolves the repo
|
||||
# and downloads it itself, so there is nothing to keep in sync.
|
||||
|
||||
@@ -43,7 +43,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: llama-embed
|
||||
image: ghcr.io/ggml-org/llama.cpp:server-vulkan-b10731
|
||||
image: ghcr.io/ggml-org/llama.cpp:server-vulkan-b10731@sha256:0262343f64f030e8efff15ec05ee8180b69b7f6745de2caa3af5148e0dcb8aba
|
||||
args:
|
||||
- --hf-repo
|
||||
- gpustack/bge-m3-GGUF:Q8_0
|
||||
|
||||
@@ -32,7 +32,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: wiki-embed
|
||||
image: ghcr.io/ggml-org/llama.cpp:server-vulkan-b10731
|
||||
image: ghcr.io/ggml-org/llama.cpp:server-vulkan-b10731@sha256:0262343f64f030e8efff15ec05ee8180b69b7f6745de2caa3af5148e0dcb8aba
|
||||
args:
|
||||
- --hf-repo
|
||||
- gpustack/bge-m3-GGUF:Q8_0
|
||||
|
||||
@@ -72,7 +72,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: element-web
|
||||
image: ghcr.io/element-hq/element-web:v1.12.30
|
||||
image: ghcr.io/element-hq/element-web:v1.12.30@sha256:3e7dbd4424de9e11c812bc740acf2725b2abe66ea0d0802023b56fd85d3fa09a
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
|
||||
@@ -137,7 +137,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: mas
|
||||
image: ghcr.io/element-hq/matrix-authentication-service:1.26.0
|
||||
image: ghcr.io/element-hq/matrix-authentication-service:1.26.0@sha256:e089f1048a1d4a9a492ed17b9fe759100f1bd619407b001f5927928d88b780c4
|
||||
# The server applies database migrations and syncs the upstream
|
||||
# providers from the config on start.
|
||||
args: ["server", "--config", "/config/config.yaml"]
|
||||
|
||||
@@ -152,7 +152,7 @@ spec:
|
||||
# first. The marker on the data PVC makes later starts skip it.
|
||||
# Debug image: the plain one has no shell for the marker check.
|
||||
- name: migrate-to-mas
|
||||
image: ghcr.io/element-hq/matrix-authentication-service:1.26.0-debug
|
||||
image: ghcr.io/element-hq/matrix-authentication-service:1.26.0-debug@sha256:46f7480f5ea4243ccb6fd2c447ea859e0230c8b3c0e676ee285d4ae6ca344ee3
|
||||
command:
|
||||
- /busybox/sh
|
||||
- -ec
|
||||
@@ -207,7 +207,7 @@ spec:
|
||||
mountPath: /data
|
||||
containers:
|
||||
- name: synapse
|
||||
image: ghcr.io/element-hq/synapse:v1.162.0
|
||||
image: ghcr.io/element-hq/synapse:v1.162.0@sha256:6b84a7bbac36f080b2d2e51e0289cf1b08b349598ea44a558df38d558f2c2311
|
||||
# Bypass /start.py: it expects to run as root and template a config
|
||||
# from env. Default mode is generate-missing-and-run, which creates
|
||||
# the signing key on first start.
|
||||
|
||||
@@ -123,3 +123,23 @@ spec:
|
||||
interval: 30s
|
||||
podTargetLabels:
|
||||
- role
|
||||
---
|
||||
# op-pg (OpenProject, REN-1363). Added with the cluster, like ha-pg, so the
|
||||
# CNPG backup/WAL rules see it from the first moment.
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: PodMonitor
|
||||
metadata:
|
||||
name: cnpg-op-pg
|
||||
namespace: openproject
|
||||
labels:
|
||||
app.kubernetes.io/name: cloudnative-pg
|
||||
release: kube-prometheus-stack
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
cnpg.io/cluster: op-pg
|
||||
podMetricsEndpoints:
|
||||
- port: metrics
|
||||
interval: 30s
|
||||
podTargetLabels:
|
||||
- role
|
||||
|
||||
@@ -71,6 +71,11 @@ spec:
|
||||
# health, not reachability — the root path returns 404 by design, since
|
||||
# the service only answers on calendar paths.
|
||||
- https://ical-proxy.renner.uno/health
|
||||
# OpenProject (REN-1363). Ungated (the app has its own login), so the
|
||||
# public name reaches the app: /health_checks/default answers 200 only
|
||||
# when the web process and op-pg are up. App health and the public
|
||||
# edge path in one target.
|
||||
- https://openproject.renner.uno/health_checks/default
|
||||
# APP-HEALTH probes for the gated monitoring UIs, from the correct
|
||||
# in-cluster vantage (monitoring-vantage-point-rule): the gate is
|
||||
# only on the ingress, so the ClusterIP /-/healthy endpoints are
|
||||
|
||||
@@ -27,7 +27,7 @@ spec:
|
||||
effect: NoSchedule
|
||||
containers:
|
||||
- name: smartctl-exporter
|
||||
image: quay.io/prometheuscommunity/smartctl-exporter:v0.14.0
|
||||
image: quay.io/prometheuscommunity/smartctl-exporter:v0.14.0@sha256:cfe22c36d7d2fac48ebf619707305acb65eb0fb670656eb80f356e606d782bc1
|
||||
# No --smartctl.device: the exporter scans and rescans on its own.
|
||||
# Device names are not stable across these machines.
|
||||
args:
|
||||
|
||||
@@ -93,7 +93,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: lidarr
|
||||
image: lscr.io/linuxserver/lidarr:3.1.0.4875-ls42 # {"$imagepolicy": "flux-system:lidarr"}
|
||||
image: lscr.io/linuxserver/lidarr:3.1.0.4875-ls43 # {"$imagepolicy": "flux-system:lidarr"}
|
||||
env:
|
||||
- name: PUID
|
||||
value: "1000"
|
||||
@@ -140,7 +140,7 @@ spec:
|
||||
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"}
|
||||
image: lscr.io/linuxserver/lidarr:3.1.0.4875-ls43 # {"$imagepolicy": "flux-system:lidarr"}
|
||||
command: ["sh", "/scripts/reconcile.sh"]
|
||||
volumeMounts:
|
||||
- name: config
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# openproject
|
||||
|
||||
OpenProject Community Edition, the owner's project board (REN-1346,
|
||||
REN-1363). <https://openproject.renner.uno>, public through the NetBird
|
||||
proxy like immich and matrix; OpenProject's own login guards it.
|
||||
|
||||
## Layout
|
||||
|
||||
- `openproject` Deployment, one pod: init `wait-for-db` and `seeder`
|
||||
(schema load or migrations, then seed), then `web` (puma, :8080) and
|
||||
`worker` (GoodJob). One pod because both need the RWO attachments PVC.
|
||||
- `op-pg` CNPG cluster (PostgreSQL 17, 2 instances), WAL archive and daily
|
||||
base backup to Hetzner `s3://renner-uno-k8s-cnpg/op-pg`, 30 days.
|
||||
- `openproject-assets` PVC (attachments), Velero `openproject-daily` at
|
||||
10:15 local, 30 days.
|
||||
- `openproject-secrets` is generated once in-cluster by ESO:
|
||||
`SECRET_KEY_BASE` and `OPENPROJECT_SEED_ADMIN_USER_PASSWORD` (login
|
||||
`admin`). The password only seeds the first start; a change in the UI is
|
||||
not reverted.
|
||||
|
||||
## Image updates
|
||||
|
||||
The image is pinned by tag and digest (`openproject/openproject:<x.y.z>-slim`)
|
||||
and Renovate opens the bump. The `seeder` init container migrates the schema
|
||||
before the new web container starts. A major bump (17 -> 18) gets its own
|
||||
card and a check of the upstream upgrade notes.
|
||||
|
||||
## Monitoring
|
||||
|
||||
- Blackbox `https://openproject.renner.uno/health_checks/default`: public
|
||||
edge, Traefik, web and database in one target (catch-all
|
||||
`BlackboxProbeFailed`).
|
||||
- `cnpg-op-pg` PodMonitor: the CNPG backup and WAL-archive rules cover op-pg.
|
||||
- Velero schedule rules cover `openproject-daily`.
|
||||
|
||||
## Restore
|
||||
|
||||
Database (physical restore of the latest base backup plus WAL):
|
||||
|
||||
1. Check a backup exists: `kubectl -n openproject get backups.postgresql.cnpg.io`
|
||||
and the bucket path `op-pg/`.
|
||||
2. Follow `kubernetes/RECOVERY.md` (cold rebuild) with this stanza in
|
||||
`cluster.yaml` in place of `bootstrap.initdb`:
|
||||
|
||||
```yaml
|
||||
bootstrap:
|
||||
recovery:
|
||||
source: op-pg-store
|
||||
database: openproject
|
||||
owner: openproject
|
||||
externalClusters:
|
||||
- name: op-pg-store
|
||||
plugin:
|
||||
name: barman-cloud.cloudnative-pg.io
|
||||
parameters:
|
||||
barmanObjectName: op-pg-store
|
||||
serverName: op-pg
|
||||
```
|
||||
|
||||
Scale the `openproject` Deployment to 0 first, so nothing writes to an
|
||||
empty database while the cluster rebuilds. Revert to `initdb` afterwards.
|
||||
3. A non-destructive test restore into a side cluster: "CNPG restore-test"
|
||||
in `kubernetes/RECOVERY.md`.
|
||||
|
||||
Attachments: scale the Deployment to 0, delete the `openproject-assets` PVC,
|
||||
then `velero restore create --from-backup <openproject-daily-...>
|
||||
--include-namespaces openproject`. Restore database and attachments from the
|
||||
same day, so attachment rows and files match.
|
||||
|
||||
`openproject-secrets`: if lost, ESO generates a new one. Sessions end; the
|
||||
admin password in the database stays what it was.
|
||||
@@ -0,0 +1,42 @@
|
||||
apiVersion: postgresql.cnpg.io/v1
|
||||
kind: Cluster
|
||||
metadata:
|
||||
name: op-pg
|
||||
namespace: openproject
|
||||
spec:
|
||||
instances: 2
|
||||
smartShutdownTimeout: 10
|
||||
stopDelay: 60
|
||||
# PG 17, not the repo-standard 16: OpenProject 17 ships its all-in-one
|
||||
# image and packages on 17 and its system requirements point there. Bare
|
||||
# major tag like the other clusters (minor bumps flow, majors stay an
|
||||
# explicit decision).
|
||||
imageName: ghcr.io/cloudnative-pg/postgresql:17
|
||||
storage:
|
||||
storageClass: ceph-block
|
||||
size: 5Gi
|
||||
plugins:
|
||||
- name: barman-cloud.cloudnative-pg.io
|
||||
isWALArchiver: true
|
||||
parameters:
|
||||
barmanObjectName: op-pg-store
|
||||
bootstrap:
|
||||
initdb:
|
||||
database: openproject
|
||||
owner: openproject
|
||||
postgresql:
|
||||
parameters:
|
||||
shared_buffers: "128MB"
|
||||
max_connections: "100"
|
||||
# Same write-reduction settings as the other clusters; see
|
||||
# apps/trmnl/cluster.yaml for the reasoning and the RPO trade.
|
||||
wal_compression: "zstd"
|
||||
archive_timeout: "30min"
|
||||
checkpoint_timeout: "15min"
|
||||
max_wal_size: "2GB"
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
memory: 512Mi
|
||||
@@ -0,0 +1,15 @@
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: hetzner-s3-creds
|
||||
namespace: openproject
|
||||
spec:
|
||||
refreshInterval: 1h
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: hetzner-s3-creds
|
||||
dataFrom:
|
||||
- extract:
|
||||
key: infrastructure/hetzner-s3
|
||||
@@ -0,0 +1,31 @@
|
||||
# Public, same edge path as immich and matrix: the dns.renner.uno/public
|
||||
# label makes netbird-service-sync publish the host through the NetBird proxy
|
||||
# (Cloudflare wildcard -> proxy.renner.uno -> TLS passthrough to Traefik) and
|
||||
# external-dns-unifi skip it; LAN clients follow the public answer and the UDM
|
||||
# hairpins back in. OpenProject's own login guards it.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: openproject
|
||||
namespace: openproject
|
||||
labels:
|
||||
dns.renner.uno/public: "true"
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts:
|
||||
- openproject.renner.uno
|
||||
secretName: openproject-tls
|
||||
rules:
|
||||
- host: openproject.renner.uno
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: openproject
|
||||
port:
|
||||
number: 80
|
||||
@@ -0,0 +1,13 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- secrets.yaml
|
||||
- pvcs.yaml
|
||||
- openproject.yaml
|
||||
- ingress.yaml
|
||||
# op-pg CNPG Postgres + offsite Barman backup (PostgreSQL 17, the version
|
||||
# OpenProject 17 targets — see cluster.yaml).
|
||||
- cluster.yaml
|
||||
- objectstore.yaml
|
||||
- scheduled-backup.yaml
|
||||
- hetzner-creds-externalsecret.yaml
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
apiVersion: barmancloud.cnpg.io/v1
|
||||
kind: ObjectStore
|
||||
metadata:
|
||||
name: op-pg-store
|
||||
namespace: openproject
|
||||
spec:
|
||||
configuration:
|
||||
# Offsite Hetzner Object Storage (fsn1), same bucket as the other CNPG
|
||||
# clusters. No SSE: Hetzner supports only SSE-C, unusable by barman-cloud
|
||||
# (plugin #646), so at-rest encryption is unavailable; transit is TLS.
|
||||
destinationPath: s3://renner-uno-k8s-cnpg/op-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 is required (REGION=fsn1) — without it boto3's HeadBucket
|
||||
# (barman-cloud-check-wal-archive during bootstrap.recovery) 400s.
|
||||
region:
|
||||
name: hetzner-s3-creds
|
||||
key: REGION
|
||||
wal:
|
||||
compression: gzip
|
||||
data:
|
||||
compression: gzip
|
||||
retentionPolicy: "30d"
|
||||
@@ -0,0 +1,224 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: openproject-config
|
||||
namespace: openproject
|
||||
data:
|
||||
OPENPROJECT_HOST__NAME: "openproject.renner.uno"
|
||||
# TLS ends at Traefik; this makes OpenProject build https:// links and set
|
||||
# secure cookies.
|
||||
OPENPROJECT_HTTPS: "true"
|
||||
# Log in with the generated password directly instead of being forced to
|
||||
# change it on first login (see secrets.yaml).
|
||||
OPENPROJECT_SEED_ADMIN_USER_PASSWORD_RESET: "false"
|
||||
# One user base and a small team: two puma workers instead of the
|
||||
# per-CPU default keep the pod inside its memory limit.
|
||||
OPENPROJECT_WEB_WORKERS: "2"
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: openproject
|
||||
namespace: openproject
|
||||
labels:
|
||||
app.kubernetes.io/name: openproject
|
||||
annotations:
|
||||
# Env vars are read once at container start; roll the pod when the
|
||||
# ConfigMap or the generated Secret changes.
|
||||
configmap.reloader.stakater.com/reload: openproject-config
|
||||
secret.reloader.stakater.com/reload: openproject-secrets
|
||||
spec:
|
||||
replicas: 1
|
||||
# Web and worker share the RWO attachments PVC, and the seeder init
|
||||
# container migrates the schema; two pods would race on both.
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: openproject
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: openproject
|
||||
spec:
|
||||
enableServiceLinks: false
|
||||
automountServiceAccountToken: false
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
# `app` in the official image.
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
fsGroup: 1000
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
initContainers:
|
||||
# Retries until op-pg accepts connections; on a fresh install the
|
||||
# cluster comes up in the same Flux apply as this pod.
|
||||
- name: wait-for-db
|
||||
image: openproject/openproject:17.9.1-slim@sha256:2beeecac788d4f423c46a95ffbd04e6479491938b9924b610e9f47d81a38303c
|
||||
args: ["/app/docker/prod/wait-for-db"]
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: openproject-config
|
||||
- secretRef:
|
||||
name: openproject-secrets
|
||||
env:
|
||||
# CNPG generates this Secret; `uri` carries the rotated password
|
||||
# and points at op-pg-rw.
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: op-pg-app
|
||||
key: uri
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
memory: 1Gi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
# Loads the schema on an empty database, else runs pending
|
||||
# migrations, then seeds (idempotent; creates `admin` on first run).
|
||||
# Runs on every pod start, so an image bump migrates before the new
|
||||
# web container serves.
|
||||
- name: seeder
|
||||
image: openproject/openproject:17.9.1-slim@sha256:2beeecac788d4f423c46a95ffbd04e6479491938b9924b610e9f47d81a38303c
|
||||
args: ["/app/docker/prod/seeder"]
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: openproject-config
|
||||
- secretRef:
|
||||
name: openproject-secrets
|
||||
env:
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: op-pg-app
|
||||
key: uri
|
||||
volumeMounts:
|
||||
- name: assets
|
||||
mountPath: /var/openproject/assets
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
memory: 2Gi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
containers:
|
||||
- name: web
|
||||
image: openproject/openproject:17.9.1-slim@sha256:2beeecac788d4f423c46a95ffbd04e6479491938b9924b610e9f47d81a38303c
|
||||
args: ["/app/docker/prod/web"]
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: openproject-config
|
||||
- secretRef:
|
||||
name: openproject-secrets
|
||||
env:
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: op-pg-app
|
||||
key: uri
|
||||
volumeMounts:
|
||||
- name: assets
|
||||
mountPath: /var/openproject/assets
|
||||
# /health_checks/default checks the database and the web process.
|
||||
# The Host header is required: the request goes to the pod IP,
|
||||
# which OpenProject's host check would answer with 404.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /health_checks/default
|
||||
port: http
|
||||
httpHeaders:
|
||||
- name: Host
|
||||
value: localhost
|
||||
periodSeconds: 10
|
||||
# ~5 min for puma to boot and preload the app.
|
||||
failureThreshold: 30
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health_checks/default
|
||||
port: http
|
||||
httpHeaders:
|
||||
- name: Host
|
||||
value: localhost
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health_checks/default
|
||||
port: http
|
||||
httpHeaders:
|
||||
- name: Host
|
||||
value: localhost
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 1Gi
|
||||
limits:
|
||||
memory: 2Gi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
# Background jobs (GoodJob): mails, notifications, exports, and the
|
||||
# cron-like jobs. Needs the attachments volume for exports/imports.
|
||||
- name: worker
|
||||
image: openproject/openproject:17.9.1-slim@sha256:2beeecac788d4f423c46a95ffbd04e6479491938b9924b610e9f47d81a38303c
|
||||
args: ["/app/docker/prod/worker"]
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: openproject-config
|
||||
- secretRef:
|
||||
name: openproject-secrets
|
||||
env:
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: op-pg-app
|
||||
key: uri
|
||||
volumeMounts:
|
||||
- name: assets
|
||||
mountPath: /var/openproject/assets
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
memory: 1536Mi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumes:
|
||||
- name: assets
|
||||
persistentVolumeClaim:
|
||||
claimName: openproject-assets
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: openproject
|
||||
namespace: openproject
|
||||
labels:
|
||||
app.kubernetes.io/name: openproject
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: openproject
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: http
|
||||
@@ -0,0 +1,19 @@
|
||||
# Attachments (OPENPROJECT_ATTACHMENTS__STORAGE__PATH defaults to
|
||||
# $APP_DATA_PATH/files = /var/openproject/assets/files). Web and worker share
|
||||
# one pod, so RWO is enough and the Deployment uses `Recreate`. Backed up by
|
||||
# the Velero schedule openproject-daily (selected by these labels).
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: openproject-assets
|
||||
namespace: openproject
|
||||
labels:
|
||||
app.kubernetes.io/name: openproject
|
||||
app.kubernetes.io/component: assets
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: ceph-block
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
@@ -0,0 +1,17 @@
|
||||
apiVersion: postgresql.cnpg.io/v1
|
||||
kind: ScheduledBackup
|
||||
metadata:
|
||||
name: op-pg-daily
|
||||
namespace: openproject
|
||||
spec:
|
||||
# 03:00 local, same slot as the other CNPG clusters. `immediate` takes the
|
||||
# first base backup as soon as the cluster is up. The attachments PVC is
|
||||
# covered by the Velero schedule openproject-daily.
|
||||
schedule: "0 0 3 * * *"
|
||||
immediate: true
|
||||
backupOwnerReference: self
|
||||
cluster:
|
||||
name: op-pg
|
||||
method: plugin
|
||||
pluginConfiguration:
|
||||
name: barman-cloud.cloudnative-pg.io
|
||||
@@ -0,0 +1,42 @@
|
||||
# Generated once in-cluster by ESO; nothing here is seeded by hand.
|
||||
#
|
||||
# SECRET_KEY_BASE Rails session/cookie key.
|
||||
# OPENPROJECT_SEED_ADMIN_USER_PASSWORD Password of the `admin` account the
|
||||
# seeder creates on the first start.
|
||||
# Only read at seed time: changing the
|
||||
# password in the UI later is not
|
||||
# reverted, and this value goes stale.
|
||||
#
|
||||
# Deleting the Secret regenerates both; that logs everyone out and does NOT
|
||||
# change an existing admin password.
|
||||
apiVersion: generators.external-secrets.io/v1alpha1
|
||||
kind: Password
|
||||
metadata:
|
||||
name: openproject-secrets
|
||||
namespace: openproject
|
||||
spec:
|
||||
length: 64
|
||||
digits: 16
|
||||
symbols: 0
|
||||
allowRepeat: true
|
||||
noUpper: false
|
||||
secretKeys:
|
||||
- SECRET_KEY_BASE
|
||||
- OPENPROJECT_SEED_ADMIN_USER_PASSWORD
|
||||
---
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: openproject-secrets
|
||||
namespace: openproject
|
||||
spec:
|
||||
# Generate once; Periodic would rotate the key every refresh.
|
||||
refreshPolicy: CreatedOnce
|
||||
target:
|
||||
name: openproject-secrets
|
||||
dataFrom:
|
||||
- sourceRef:
|
||||
generatorRef:
|
||||
apiVersion: generators.external-secrets.io/v1alpha1
|
||||
kind: Password
|
||||
name: openproject-secrets
|
||||
@@ -48,7 +48,7 @@ spec:
|
||||
#
|
||||
# Hence socat is installed at startup and run inside this container.
|
||||
- name: otbr
|
||||
image: openthread/otbr:latest
|
||||
image: openthread/otbr:latest@sha256:49b352ce39dd6514b486af7397356c5cf07ed3f090a48ca3006cb72ec45a46f8
|
||||
command:
|
||||
- bash
|
||||
- -c
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
# `paperclip-prometheus-read`. Atlas, Iris, Hestia and Argus get exactly what
|
||||
# they hold today through that shared account: built-in `view` cluster-wide
|
||||
# (no Secrets, no write verb) plus the `monitoring/paperclip-prometheus-read`
|
||||
# Role (see prometheus-read-rbac.yaml). Hermes gets only `get` on the five
|
||||
# token Secrets below, to issue and rotate the kubeconfigs.
|
||||
# Role (see prometheus-read-rbac.yaml). Hermes gets `get` on the five token
|
||||
# Secrets below, to issue and rotate the kubeconfigs, plus that Prometheus
|
||||
# Role (REN-1345).
|
||||
#
|
||||
# Token Secrets carry no data in git; kube-controller-manager fills
|
||||
# `data.token`. Flux applies server-side and does not own `data`, so it does
|
||||
@@ -159,7 +160,9 @@ roleRef:
|
||||
# Existing Role in prometheus-read-rbac.yaml, unchanged.
|
||||
name: paperclip-prometheus-read
|
||||
# Subjects live in `paperclip`, the binding in `monitoring`: `namespace:` is
|
||||
# load-bearing.
|
||||
# load-bearing. agent-hermes is bound here too (REN-1345): its duty list reads
|
||||
# Prometheus, and this Role is `get` on three named read-only proxies only.
|
||||
# It does not get `view`.
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: agent-atlas
|
||||
@@ -173,6 +176,9 @@ subjects:
|
||||
- kind: ServiceAccount
|
||||
name: agent-argus
|
||||
namespace: paperclip
|
||||
- kind: ServiceAccount
|
||||
name: agent-hermes
|
||||
namespace: paperclip
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
@@ -212,3 +218,47 @@ subjects:
|
||||
- kind: ServiceAccount
|
||||
name: agent-hermes
|
||||
namespace: paperclip
|
||||
---
|
||||
# MQTT read access for agents (REN-953): `get` on the one Secret that holds
|
||||
# the agents-read client certificate (issued by mqtt-account-controller,
|
||||
# renewed by cert-manager). Agents fetch it per run, so a renewal needs no
|
||||
# restart. The certificate can only read open3e/#, zigbee2mqtt/# and
|
||||
# homeassistant/# on the broker (register mqtt-accounts.yaml). Hermes is not
|
||||
# bound: it issues kubeconfigs and has no MQTT task.
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: mqtt-agents-read
|
||||
namespace: paperclip
|
||||
labels:
|
||||
app.kubernetes.io/name: paperclip
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
resourceNames: ["mqtt-agents-read-tls"]
|
||||
verbs: ["get"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: mqtt-agents-read
|
||||
namespace: paperclip
|
||||
labels:
|
||||
app.kubernetes.io/name: paperclip
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: Role
|
||||
name: mqtt-agents-read
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: agent-atlas
|
||||
namespace: paperclip
|
||||
- kind: ServiceAccount
|
||||
name: agent-iris
|
||||
namespace: paperclip
|
||||
- kind: ServiceAccount
|
||||
name: agent-hestia
|
||||
namespace: paperclip
|
||||
- kind: ServiceAccount
|
||||
name: agent-argus
|
||||
namespace: paperclip
|
||||
|
||||
@@ -74,6 +74,18 @@ spec:
|
||||
- ports:
|
||||
- port: "8443"
|
||||
protocol: TCP
|
||||
# mosquitto mTLS listener (REN-953): agents that run in this pod read MQTT
|
||||
# with the agents-read client certificate. :8884 only; the listener
|
||||
# rejects any connection without an mqtt-ca certificate. Paired with
|
||||
# mosquitto-agent-ingress in network-policies/home-assistant-ns.yaml.
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": home-assistant
|
||||
app.kubernetes.io/name: mosquitto
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8884"
|
||||
protocol: TCP
|
||||
# Notes wiki API, authenticated by the paperclip API key.
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
|
||||
@@ -34,7 +34,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: paperclip
|
||||
image: gitea.renner.uno/konairius/paperclip:build.1791297080@sha256:0764e882521410d8ce8ed1359f8a1aa64eaa7746aa909653ef70050c239ca195 # {"$imagepolicy": "flux-system:paperclip"}
|
||||
image: gitea.renner.uno/konairius/paperclip:build.1791487850@sha256:ff81da300e6c35581f34fad61a55da771b0e991d2fb2dcb624a0361263f01a37 # {"$imagepolicy": "flux-system:paperclip"}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3100
|
||||
|
||||
@@ -10,4 +10,4 @@ spec:
|
||||
storageClassName: ceph-block
|
||||
resources:
|
||||
requests:
|
||||
storage: 20Gi
|
||||
storage: 40Gi # ceph-block expands online; 20Gi filled in ~12 days (#1184)
|
||||
|
||||
@@ -134,7 +134,7 @@ spec:
|
||||
# is a device-bricking class of risk. Bumps are a reviewed, manual
|
||||
# act — see kubernetes/infrastructure/image-automation/README.md.
|
||||
- name: terminus
|
||||
image: ghcr.io/usetrmnl/terminus:0.76.0 # {"$imagepolicy": "flux-system:terminus"}
|
||||
image: ghcr.io/usetrmnl/terminus:0.77.0 # {"$imagepolicy": "flux-system:terminus"}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 2345
|
||||
@@ -197,7 +197,7 @@ spec:
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
- name: sidekiq
|
||||
image: ghcr.io/usetrmnl/terminus:0.76.0 # {"$imagepolicy": "flux-system:terminus"}
|
||||
image: ghcr.io/usetrmnl/terminus:0.77.0 # {"$imagepolicy": "flux-system:terminus"}
|
||||
command: ["bundle", "exec", "sidekiq", "-r", "./config/sidekiq.rb"]
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
|
||||
@@ -57,7 +57,7 @@ spec:
|
||||
# Pinned, and deliberately NOT under Flux image automation. rbac.yaml
|
||||
# is derived from what this exact version calls; an automatic bump
|
||||
# could add a call the Role does not cover, silently.
|
||||
image: ghcr.io/stakater/ingressmonitorcontroller:v2.2.13
|
||||
image: ghcr.io/stakater/ingressmonitorcontroller:v2.2.13@sha256:3301afb61c10abc4e334deab9cd8601cef069e352b4d7f4dd0897787fca32eac
|
||||
command: ["/manager"]
|
||||
args:
|
||||
- --health-probe-bind-address=:8081
|
||||
|
||||
@@ -34,7 +34,7 @@ spec:
|
||||
# Image automation, range 3.x (see image-automation/woodpecker.yaml).
|
||||
# MUST stay version-matched with woodpecker-server (gRPC parity) — both
|
||||
# track the same range/filter so they bump together on each release.
|
||||
image: woodpeckerci/woodpecker-agent:v3.18.1 # {"$imagepolicy": "flux-system:woodpecker-agent"}
|
||||
image: woodpeckerci/woodpecker-agent:v3.19.0 # {"$imagepolicy": "flux-system:woodpecker-agent"}
|
||||
env:
|
||||
- name: WOODPECKER_SERVER
|
||||
value: woodpecker.woodpecker.svc.cluster.local:9000 # in-cluster gRPC, replaces LXC :9000
|
||||
|
||||
@@ -40,7 +40,7 @@ spec:
|
||||
# minor + patch auto-update within 3.x; only the 4.0 major is held for
|
||||
# a reviewed bump. Safe now the DB is postgres (the old sqlite-surprise
|
||||
# -bump hazard is gone). Stays in lockstep with the agent.
|
||||
image: woodpeckerci/woodpecker-server:v3.18.1 # {"$imagepolicy": "flux-system:woodpecker-server"}
|
||||
image: woodpeckerci/woodpecker-server:v3.19.0 # {"$imagepolicy": "flux-system:woodpecker-server"}
|
||||
env:
|
||||
- name: WOODPECKER_HOST
|
||||
value: https://ci.renner.uno
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: openproject
|
||||
namespace: flux-system
|
||||
spec:
|
||||
# OpenProject Community (REN-1363): web + worker, the op-pg CNPG Cluster +
|
||||
# ObjectStore + ScheduledBackup and the attachments PVC. Own KS so a
|
||||
# failure here never holds up the shared apps KS.
|
||||
interval: 10m
|
||||
retryInterval: 1m
|
||||
timeout: 30m
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: flux-system
|
||||
path: ./kubernetes/apps/openproject
|
||||
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)"
|
||||
@@ -131,7 +131,7 @@ spec:
|
||||
# to run on every node (and re-run if the node reboots / the binary
|
||||
# is wiped). Pause image keeps memory near zero.
|
||||
- name: pause
|
||||
image: registry.k8s.io/pause:3.10
|
||||
image: registry.k8s.io/pause:3.10@sha256:ee6521f290b2168b6e0935a181d4cff9be1ac3f505666ef0e3c98fae8199917a
|
||||
terminationGracePeriodSeconds: 5
|
||||
volumes:
|
||||
- name: cnibin
|
||||
|
||||
@@ -167,7 +167,7 @@ spec:
|
||||
SLEEP=false source /install-cni.sh
|
||||
/token-watcher.sh &
|
||||
/ip-control-loop -log-level debug
|
||||
image: ghcr.io/k8snetworkplumbingwg/whereabouts:v0.9.4
|
||||
image: ghcr.io/k8snetworkplumbingwg/whereabouts:v0.9.4@sha256:f279fd7dc1124d7819330fb2e07357990b2b4c5d8733aef1b028573c29f05e14
|
||||
env:
|
||||
- name: NODENAME
|
||||
valueFrom:
|
||||
|
||||
@@ -51,7 +51,7 @@ spec:
|
||||
args:
|
||||
- -log-level
|
||||
- debug
|
||||
image: ghcr.io/k8snetworkplumbingwg/whereabouts:v0.9.4
|
||||
image: ghcr.io/k8snetworkplumbingwg/whereabouts:v0.9.4@sha256:f279fd7dc1124d7819330fb2e07357990b2b4c5d8733aef1b028573c29f05e14
|
||||
env:
|
||||
- name: WHEREABOUTS_NAMESPACE
|
||||
valueFrom:
|
||||
|
||||
@@ -152,6 +152,12 @@ kind: Namespace
|
||||
metadata:
|
||||
name: matrix
|
||||
---
|
||||
# OpenProject Community, the owner's project board (REN-1363).
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: openproject
|
||||
---
|
||||
# Terminus (TRMNL BYOS server) driving the entrance-way e-ink panel.
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
|
||||
@@ -439,3 +439,39 @@ spec:
|
||||
- ports:
|
||||
- port: "8081"
|
||||
protocol: TCP
|
||||
---
|
||||
# Agent sandbox pods -> mosquitto :8884 (REN-953). The listener requires a
|
||||
# client certificate from mqtt-ca; agents hold only agents-read, which may
|
||||
# read open3e/#, zigbee2mqtt/# and homeassistant/# and write nothing.
|
||||
# DEDICATED policy selecting mosquitto alone, like shelly-manager-agent-ingress.
|
||||
#
|
||||
# Revert: delete this document and agent-egress-mosquitto.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: mosquitto-agent-ingress
|
||||
namespace: home-assistant
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: mosquitto
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": paperclip-renner
|
||||
paperclip.io/role: agent
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8884"
|
||||
protocol: TCP
|
||||
# The paperclip pod itself: most agent runs execute there, not in a
|
||||
# sandbox (REN-1001). Egress side: paperclip-egress in
|
||||
# apps/paperclip/network-policy.yaml.
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": paperclip
|
||||
app.kubernetes.io/name: paperclip
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8884"
|
||||
protocol: TCP
|
||||
|
||||
@@ -27,6 +27,7 @@ resources:
|
||||
- gitea-ns.yaml
|
||||
- netbird-service-sync-ns.yaml
|
||||
- trmnl-ns.yaml
|
||||
- openproject-ns.yaml
|
||||
- matrix-ns.yaml
|
||||
- wordpress-ns.yaml
|
||||
- ai-ns.yaml
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: default-deny-ingress
|
||||
namespace: openproject
|
||||
spec:
|
||||
endpointSelector: {}
|
||||
ingress:
|
||||
# Intra-namespace — openproject → op-pg.
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": openproject
|
||||
# openproject.renner.uno via Traefik.
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": ingress
|
||||
app.kubernetes.io/name: traefik
|
||||
# Prometheus scrape.
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": monitoring
|
||||
# Velero backups via PVC mount.
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": velero
|
||||
# CNPG operator (in `database` ns) reconciles op-pg — needs to reach
|
||||
# the postgres pods for health, failover, role checks.
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": database
|
||||
- fromEntities:
|
||||
- host
|
||||
- remote-node
|
||||
# ICMP — all standard ICMPv4 + ICMPv6 from any cluster pod. See
|
||||
# home-assistant-ns.yaml for the rationale. INGRESS ONLY.
|
||||
- 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 for the OpenProject pod only, so the op-pg CNPG pods keep
|
||||
# unrestricted egress for the barman-cloud WAL archive to Hetzner (same trade
|
||||
# as trmnl-ns.yaml).
|
||||
#
|
||||
# OpenProject needs nothing outside the namespace today: no SMTP, no OIDC, no
|
||||
# file-storage integration. Adding one of those needs a rule here, or it
|
||||
# fails closed.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: openproject-egress
|
||||
namespace: openproject
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: openproject
|
||||
egress:
|
||||
# Intra-namespace — op-pg-rw.
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": openproject
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": kube-system
|
||||
k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
@@ -166,3 +166,28 @@ spec:
|
||||
- ports:
|
||||
- port: "8081"
|
||||
protocol: TCP
|
||||
---
|
||||
# Agent pods -> mosquitto mTLS listener :8884 (REN-953), for the read-only
|
||||
# certificate account agents-read. Not :1883/:8883: those take passwords, and
|
||||
# agents hold none. Matching ingress: mosquitto-agent-ingress in
|
||||
# home-assistant-ns.yaml. Not labelled `paperclip.io/managed-by`.
|
||||
#
|
||||
# Revert: delete this document and mosquitto-agent-ingress.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: agent-egress-mosquitto
|
||||
namespace: paperclip-renner
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
paperclip.io/role: agent
|
||||
egress:
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": home-assistant
|
||||
app.kubernetes.io/name: mosquitto
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8884"
|
||||
protocol: TCP
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# 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,7 +48,6 @@ 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:
|
||||
#
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
---
|
||||
# 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 *"
|
||||
# Suspended (REN-802): the ESP32 only gets its password by hand, so a
|
||||
# rotation would take the gateway offline. MQTT credentials move to the
|
||||
# git-defined account register; this rotator is removed in that cleanup.
|
||||
suspend: true
|
||||
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
|
||||
@@ -22,12 +22,14 @@ spec:
|
||||
- crowdsec
|
||||
- evcc
|
||||
- flux-system # flux-system-git-auth: syncs the rotated flux gitea PAT into Flux's git secret
|
||||
- gitea # gitea-package-retention token (apps/gitea/package-retention.yaml, REN-1203)
|
||||
- home-assistant
|
||||
- immich
|
||||
- influxdb
|
||||
- ingress
|
||||
- matrix # synapse-secrets, mx-pg barman creds (apps/matrix)
|
||||
- monitoring
|
||||
- openproject # op-pg barman creds (apps/openproject)
|
||||
- paperclip
|
||||
- paperless
|
||||
- renovate # renovate-bot token (apps/renovate/externalsecret.yaml)
|
||||
|
||||
@@ -368,3 +368,30 @@ spec:
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: synapse
|
||||
---
|
||||
apiVersion: velero.io/v1
|
||||
kind: Schedule
|
||||
metadata:
|
||||
name: openproject-daily
|
||||
namespace: velero
|
||||
labels:
|
||||
app.kubernetes.io/part-of: homelab-backup
|
||||
spec:
|
||||
# Daily at 10:15 local (next free stagger slot after matrix-daily at
|
||||
# 09:45). openproject-assets only: the attachments. Selected by the label
|
||||
# this repo sets, like wordpress-daily; the op-pg database is covered by
|
||||
# Barman.
|
||||
schedule: "15 10 * * *"
|
||||
useOwnerReferencesInBackup: false
|
||||
template:
|
||||
includedNamespaces:
|
||||
- openproject
|
||||
includedResources:
|
||||
- persistentvolumeclaims
|
||||
storageLocation: default
|
||||
ttl: 720h0m0s
|
||||
snapshotMoveData: true
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: openproject
|
||||
app.kubernetes.io/component: assets
|
||||
|
||||
Reference in New Issue
Block a user