diff --git a/.env.example b/.env.example
index fb0c72f..04280f9 100644
--- a/.env.example
+++ b/.env.example
@@ -36,6 +36,12 @@ JOKER_DYNDNS_USERNAME=
JOKER_DYNDNS_PASSWORD=
REGISTRY_USERNAME=
REGISTRY_PASSWORD=
+REDMINE_URL=
+REDMINE_API_KEY=
+REDMINE_PROJECT_ID=
+REDMINE_TRACKER_BUG_ID=
+REDMINE_TRACKER_SECURITY_ID=
+REDMINE_ASSIGNEE_ID=
# Admin bootstrap (used by seed/reset scripts)
ADMIN_EMAIL=
diff --git a/creds/secrets.enc.env b/creds/secrets.enc.env
index 21fee45..f0dd03c 100644
--- a/creds/secrets.enc.env
+++ b/creds/secrets.enc.env
@@ -1,44 +1,50 @@
# Encrypted with sops (age). To edit: sops creds/secrets.enc.env
-AUTH_SECRET=ENC[AES256_GCM,data:DQl4MEDrsUe76/NYdI03BZb31/NTTA0nBTUALhsULvT1EvMRRiR1,iv:rXjuUm3Z5OFMa0Jc/h8BSF1DWxddrtH2DyvPwX8KZ3c=,tag:eL7NZj0WE44y9AxVoCXrFA==,type:str]
-DATABASE_URL=ENC[AES256_GCM,data:87ysYMPY0lI8LrL46dHPjcZOYo4IlP/aQ4h+psSXS4EKzUiBJuExtF8f+kAZNJBc4Xj+B82Q34CfbbsBC8NdQoeebqvXF6/0IluvmpCBhpN2MKWqwlx/mQZTENZA3J0QiAP+BapeZXuXLc//,iv:n4W+hyWf1oH8IahJJs1XQ3d3i5hJjLXaNE+b4Z66Zbw=,tag:JYsBd5d0APkikYYg8Ea/Fg==,type:str]
-DB_HOST=ENC[AES256_GCM,data:qlmMVJEkDwfiRYGFUdP/,iv:syUEM2Cx64jE7IW8zRSqfe9uAL+JEMl9Tu5nw12kdWI=,tag:VoAJgXgGy8OXksUvn/SpUQ==,type:str]
-DB_PORT=ENC[AES256_GCM,data:vLjEIHpK,iv:ym9R0E/S5WKLKF+0thz9tmwI4pYa7skrY4sDpY42y0E=,tag:SK8gDGrnx8/YAPi8qB7KeQ==,type:str]
-DB_USER=ENC[AES256_GCM,data:KIlxtjxjx0EIDxXxHKY=,iv:dlhX4pIe6AE8fzT+TVB3hg9gJ46sq85Qp8f4pkxO9n8=,tag:WCMaOPpnI8SA8WMRDA392w==,type:str]
-DB_PASS=ENC[AES256_GCM,data:CJEh2+yQrPptlTnthe2UWsc/baNXBIAC0lpxtm8DI47gTg==,iv:UPrDDlMbWaiAiz5QcjRqXRIiVohMRF7wtmi5ioONcLE=,tag:jLSfXcefy8+cll8bVclkSQ==,type:str]
-APP_URL=ENC[AES256_GCM,data:1vZsUQ/d4MTbBM+juPJeVwseAVz4O2M=,iv:KrsOr3w1i3j3R19KAowFxFbdSm1B9IikqRoqU9tmQus=,tag:BwR3v/2R7bAFxHnB5waFIA==,type:str]
-SMTP_HOST=ENC[AES256_GCM,data:H+47VDofg1EEBo6q1UEleQ==,iv:KlAxFj/bKOMikubGbqPYkKPZt18oIdXxrLjEVnbxhN8=,tag:LpC930G7lsjt9YuSEFxW8A==,type:str]
-SMTP_PORT=ENC[AES256_GCM,data:F6fiIz4=,iv:Pelfn6ciIGvqF4nMPX5WrsWqkLtAxPBmBOSBvn4RUmo=,tag:UWBGXnDAYf5zCQJagiSE9Q==,type:str]
-SMTP_USER=ENC[AES256_GCM,data:CnbiHSRC0w/TOwwUwto=,iv:s8tJxs3X3BaFf5LrR+hHVvRIbDEfNI6ZszdTi+lg3PQ=,tag:DAbkk3szBzmhUrNTPZdA6A==,type:str]
-SMTP_PASS=ENC[AES256_GCM,data:GmmDQ2mROFz9zpGpXgtwlUvU2dmWg9KHsw==,iv:pl7O1QCjkaaqZ0sGS6kIBUKJ5glE8FTyA1vCYz0BfoU=,tag:/eE1ZWrLlKsa043VY3p3DQ==,type:str]
-SMTP_FROM=ENC[AES256_GCM,data:XY9BFPRmpO5KblsSMB2680DNx/bvUndfXA==,iv:yCUsaOMu1MrHtfR9AwfuIscyiEqsl0OYvO8UiKO6IYk=,tag:MpEIu2UJXEjE+9n1MVaHRQ==,type:str]
-SMTP_TLS=ENC[AES256_GCM,data:sBPcDhWs,iv:lZRjMBtrtAinC1blRFE0RH5jzyEcdLf+UOgEHuKitEk=,tag:R3Q4+ri3m5LB+xzohsYJHA==,type:str]
-SMTP_SSL=ENC[AES256_GCM,data:dYAo31bHTw==,iv:OG0c0HYzewLwlsgPlyn0nz+vwdeuSCjJ5ifdE9bfQvI=,tag:EJ5CX6+weXcrcjdrr1uicg==,type:str]
-SMTP_REJECT_UNAUTHORIZED=ENC[AES256_GCM,data:HhBgopyX,iv:7W9TYVN1aEH5QWK1NXGE9mw9gCd7yagg/LFVNHSdMV4=,tag:rcWGdoXH2wXtqWaImLEfHA==,type:str]
-DKIM_SELECTOR=ENC[AES256_GCM,data:taqEDa6Ah1G6yQ==,iv:n4KfE3lFBIyndBFkgvoBvzAlqP2HV4RIyL8c7lLoQSU=,tag:nos80rdqlj7YQshANBoTvw==,type:str]
-DKIM_DOMAIN=ENC[AES256_GCM,data:ZQkoRT8bQ7/Kdt0O/M4kRB8=,iv:wSbk2ALpbcxRT8neFrcQBEj8ja54ZtWeKTfllm8Pr5s=,tag:o5ZvS1enT31pV4+EM8i5LA==,type:str]
-DKIM_PRIVATE_KEY_PATH=ENC[AES256_GCM,data:8hbyhw/+I1VWGzoDNIKMY3avRvSmt17EmHiwv7WpeJZzalEXqTmDB+bpkVEQ,iv:/7bUU6boXo1HPccvNBCDnLyql0hGaMXmGIeQEqpFnwM=,tag:nTE+dR0JB/Y1ri5h/FaC3A==,type:str]
-AUTO_APPROVE_LISTINGS=ENC[AES256_GCM,data:bAHiVYhjXw==,iv:/jKFQ5L+meSNF6tn54Dc8dpK1I0+zG/o/F0QqMInmwU=,tag:9f7mb2QN17AoualTjkLeSQ==,type:str]
-OPENAI_API_KEY=ENC[AES256_GCM,data:+Avly5xD/jgkeiiHg7WNVH/OyGRf9L+iKDh/SvD7LrFuJlltORwzY253l29vD6cucaYfFanTWwDaV6TKuatNiRXxzy7aSoOfs0y4qonXofIRb3BH2/DarGWxQtRi5LRrXFIpCRQfN7x+XGbeF8ZZBW/DHFYywH0E2fU9li/iafXVOKBRcV4WYTIA3sJJuObHFjxgRYMB2SWtw8noZvkK+pzQi6lCKA==,iv:WTbvAzkjoDLAuDIdbFjt9BU5+2pGDv5Ksc4xru0Wx20=,tag:b8ddMgRoq+kMlS994yPx9A==,type:str]
-OPENAI_TRANSLATIONS_KEY=ENC[AES256_GCM,data:QGIhIhzn775yXZq9OYKTwGwcLnQAIJn8TM95TRFb5jWY/gTevspeYLDVrrGDKv/WHX8aTg4r4z9t6cbgbSX677fSlrNy2c2YEb5uCLGh0QUK2LoPt9R9yF/iyi5CU2obRlqamtF6knUGG5mdOCewKx/k+updDLmOe+CPD91H6nWbE9+1Pmbdf22bM+rOt9t4+MS6UP0UbIYU0sBEtfPTvN68260fUA==,iv:eYzIudN/i0A38X1up+Jnxv7qE5aiUEu0WQHEtQ4DvlA=,tag:qM7noB1GvXt2F69aVd0KVQ==,type:str]
+AUTH_SECRET=ENC[AES256_GCM,data:70URX0qSS/z4AYb58kAmGA/gnEt9bBGPPN5L/xpg3vsna8A9jw+X,iv:iKicMbvIaBvPXncKcmJwsbjQzjxOa7xmkm8SXeZ8RUQ=,tag:2CXRV4I5YVz52aFm65WGhQ==,type:str]
+DATABASE_URL=ENC[AES256_GCM,data:t/E/j249UXAOY9gcnb15ozJOmvDC7Ffb9RnfzRUvH9HXEqVQPlh8jM8qonygiMHmXcWYqVnqtRcFbExGYNo1h6ZRoAMcHhGfA2RqagLB8JtuDYvJzd9dsdc1z/DdwBDqWiQy2s1hzYBYTSpa,iv:/1BkhzropZ+ZHMN1aCpllI//aCa5qU4T0n765LMsvW8=,tag:6wH1Aly//CIRGAANVz7RdQ==,type:str]
+DB_HOST=ENC[AES256_GCM,data:Jvk5Zes8XH50096YHApn,iv:GDU3tu24u8lwuMLQbrv1chNLspzW8oIaPgS9ZgiZkro=,tag:vmTdIZL2E7CRkb6TYxr5eg==,type:str]
+DB_PORT=ENC[AES256_GCM,data:DXNr+ZA2,iv:9kY1pEVj9LteVs/GPnCdZrblI+8ubrqNXwiS8IW4954=,tag:YgJalc+nsdYD6m4tNayJcw==,type:str]
+DB_USER=ENC[AES256_GCM,data:K+rYqv9CQpV5p8zDXV0=,iv:TiGMbFAiyVPtbe0JmXxsilAYv4WmgTdPu4muCgSmSgY=,tag:4/kA2T3w7BUytcW0CVyFCg==,type:str]
+DB_PASS=ENC[AES256_GCM,data:w49QEtuN2mFthnn4X/DvoxwbhImYVqDUYG1Ptq4ba22P9w==,iv:cSOmskpZAi/aYpzj3+QBmVIQZfEs2itDqaMZA9eX8v8=,tag:NJrdaN6zIRE0DBpVTzDL7g==,type:str]
+APP_URL=ENC[AES256_GCM,data:xr9j3Gt/y5Uq8bHzTyTKsYtpSRg4GKU=,iv:oV68MYwwipB5tLpXXDFln2YF4hPHWiufyRm3csamssg=,tag:Bj1K3tC3LyP8ezNJG8bpIA==,type:str]
+SMTP_HOST=ENC[AES256_GCM,data:wXOHpMWXQ2/spsVqVDH07w==,iv:FHffrNhUk9sY5Ew3b9h6LBQgNCRfCu3kFMKBOYXMY/o=,tag:/PJ/UrZHHbyWYg30+C6dfw==,type:str]
+SMTP_PORT=ENC[AES256_GCM,data:6r6Zsqg=,iv:eNzU1Dk3vx7kwJu72tMZDx4r9ngHUKx1t1zAYSyFFb4=,tag:aqryLBX9xPM6DvGd5Fwl5g==,type:str]
+SMTP_USER=ENC[AES256_GCM,data:IVpG+wm7l0Kk8UfhrAo=,iv:GiQChcgXDzD/h8fBHXAeKWez3IXR4NX90NU9582hThg=,tag:b8jcQY2G2HAo3pQjc7+B0Q==,type:str]
+SMTP_PASS=ENC[AES256_GCM,data:siqtgQg2WRrRbD6yqTawLAo++u2fWvpVLg==,iv:rL5AZQ6b3PaOJhG3vGZYzXeFxm6yd+qhxNzYIN6pxQk=,tag:r0LTjC8DQ9rsLTQa4XLhAg==,type:str]
+SMTP_FROM=ENC[AES256_GCM,data:WH8AW4SVhZkeXB6cNZCfj7opSTwoqYm8TA==,iv:Hy55AgFV0VSurplLVuPPqtLcyDQr/IIvGHF6Ppl01t0=,tag:vKtXoZRFgSeMYqSL9enElQ==,type:str]
+SMTP_TLS=ENC[AES256_GCM,data:42eaSf82,iv:sMLqYMHN5hzu9wrjmH9DhHcSPK06f0ZGqv/HPMsMPo8=,tag:rxoUQyT2jEC2VjRJMJV4Sw==,type:str]
+SMTP_SSL=ENC[AES256_GCM,data:Am3rKylCqQ==,iv:P6nQdWsV1QkBYfg2oeR3JbEsJYNGvd0h/ZFza2PObY8=,tag:z5VAAD4IjNi7hMISgjsisQ==,type:str]
+SMTP_REJECT_UNAUTHORIZED=ENC[AES256_GCM,data:1wLP1f9a,iv:exUSGxwA7CZ6IuBTBuAikerCe+algCybvhW6xji6h2U=,tag:Ehf8ok6P5Bx2Ky6kzOplIQ==,type:str]
+DKIM_SELECTOR=ENC[AES256_GCM,data:gv0POpTZG/69Cw==,iv:eBeiuZPNWo6DPQ25Uupo8+1EacBGOx3FUOR9arhELV8=,tag:/6gICHQGYlO4KLeu/r0VcA==,type:str]
+DKIM_DOMAIN=ENC[AES256_GCM,data:j+C08N0l0+7sM1UFgVtST5A=,iv:YB0enFjwfhhf4wJTN61saaCJx1qnUnHrroLzrlna96E=,tag:Ni6Q31jrzhgfFNvjIRcSNA==,type:str]
+DKIM_PRIVATE_KEY_PATH=ENC[AES256_GCM,data:JAykp2gN4keLwzM962rgdJ6T9af+4WFsD5VRUBaIedf5v5v7cGJNJ+FtdrUV,iv:Ds5/3TTcbepjIMXW2YJbEKZ/aBlqk2OlEv+dIFOcQKo=,tag:Sdwnh33uMnnbPlD73rvwRw==,type:str]
+AUTO_APPROVE_LISTINGS=ENC[AES256_GCM,data:gkLX0/36sA==,iv:ma0etTesxiB49Ohgtes/2obnrvSJpmRlDZZX1gbXYt4=,tag:OFHkkH0QOEJ9PVBQTauvTg==,type:str]
+OPENAI_API_KEY=ENC[AES256_GCM,data:eVJRCNymtUQOMl5IMySoGcxGC+pVoDvIyCD6idP7CoZQFqE+y5dJ1i4LW7Y9Rb5Su8+rutwkcVu2gh3wx9XxgEfFazHKQm1lm9QaeCvKWrT/MkO26kCPAJO/vkFUp8OFnK35TG5v/RrHZZUwviV7YXEcjqvB82E59Tz6AzqlUvt1ENloK7EsK5DkkSZUdapzdMcegwqOaUUpMxZn8n/lU+pWgAp/RQ==,iv:9hLQOS5qNllbLF2Ggi5GgUENHtnM9zHBxHAnv8ppaow=,tag:+8TAn4UmxZDtQoDbeDsGDQ==,type:str]
+OPENAI_TRANSLATIONS_KEY=ENC[AES256_GCM,data:Se/eY/f00J2NLSVgvh4EMyklMHDJvnkpmzdqZl2oZWMxnLdxtpTBh8nKEfO2I9ovChdMacRseXJ2J1+oBk/1zw7xSqMastwg5EdftC4y2G8TODkPwWqDnB1t1jNpObA/gXeSxtUdE8JtM8dU4U37+KSMRjM7afdQy++YHRes1OzqBnFUdFvAwNHfi//uya0Q4CEl2unsiha8iLWaNT+skf27EgFOUQ==,iv:oBu3SPls776zktVWH7t9OwEI/p3h4WM3jWEJzluI64M=,tag:tRyJtqFkuxdMcFqEfi7RbQ==,type:str]
HETZNER_API_TOKEN="hoRULGviS8G3OGaJ68josx00M53efhuntVM5Rfft1AOvUR0ZQTXlO6yivhGqBM5o"
-HCLOUD_TOKEN=ENC[AES256_GCM,data:0qm+oR2daCd9e0qc+2wApobRod0RsUeA2QL1kQarQW77FvPAgQ7jBkML39kBP3xsYsrdmiZuIdg63Q3xeBQ9mVq2,iv:JUjR7vGyX00TOlUCOD8M9jiB6iio4IiX73sLzW/CADA=,tag:9p0kgDD7ul8s5T0cL7vsfA==,type:str]
-HETZNER_TOKEN=ENC[AES256_GCM,data:e95QAx49/qkoBnAmyup7NBs/Xm/fkWutMIWzlAv4P1ybSJNoCyC4ZibpNTCtYtK/3Hux5ItdOBm/jhssjlX363cI,iv:4UzDqJd0mYP3IJgeMn9xz9UjsVyODZ44CDAz5PPaXbk=,tag:ZEAqueoXY4kBfe+89nA+Kg==,type:str]
-JOKER_DYNDNS_USERNAME=ENC[AES256_GCM,data:FBAh4B+vxosvxfHbrZukSNea,iv:4FexfnCieAaZ7iUSvaiATr5THpqoHaebX/QigA9Wl58=,tag:dY6lQnHTJWOZa7WhHwPELA==,type:str]
-JOKER_DYNDNS_PASSWORD=ENC[AES256_GCM,data:1neBKFvQhFGfOgdPI6EHZKKI,iv:fC1J7QjAoQMUKxx6lMO32n6zjQ5tQa/icBDn19Lih2k=,tag:W110zqgwbsrVbkZzav/F0Q==,type:str]
-REGISTRY_USERNAME=ENC[AES256_GCM,data:Mt1mk15DPH8=,iv:Hxg4fRLRZ2hKRACTIIUMSODRBOgRv5Y6KfZ8477cwc0=,tag:Ufq2jYQjYzjiB88PF0gpBA==,type:str]
-REGISTRY_PASSWORD=ENC[AES256_GCM,data:4l2oMR65+X+1,iv:cfh6Esayb6zJEqKrtfY0LAncIrPi7lj/ckcbMgTMFys=,tag:vhsiagOyGVjN7+GNPwZ8LA==,type:str]
-NETDATA_USER=ENC[AES256_GCM,data:6laH3H7JKgpU,iv:PCI8HL6S68AaBRg11YlNbK9R12G1ROMqRIpXKIlsBDQ=,tag:qL1uWrtWz5+c6f6Xbv4Low==,type:str]
-NETDATA_PASS=ENC[AES256_GCM,data:CZxY7WeoHuUNmECOiL6XLVeTt/k5PAHFyV4dVBrF,iv:Zp00OVvA5hI8c9AD4pvDB1s99yw9iOlgBV4K4nbFFVg=,tag:Y077Bp8qk/SoEOajOdvMLg==,type:str]
-NETDATA_HOST_NODE1=ENC[AES256_GCM,data:QEHB348q+BQubmvIJkH/8mie2fjb0mE=,iv:ZUNZA64cuflqQq+JS/e0wByTzny0eE/4rvaxzNN1JFw=,tag:YfmJWJCd1MQW2Q2bWferLg==,type:str]
-NETDATA_HOST_DB1=ENC[AES256_GCM,data:B8mSqI+rusKxazFcziGabYMM1i1q,iv:1hDrheKjdo7dMF1m7qOWQ9llye2glEQm79LONauLiYk=,tag:H1BVhIKfymLBzGS6CZEp3Q==,type:str]
-NETDATA_PG_USER=ENC[AES256_GCM,data:n0G+MZWEWQK9,iv:11id08TbMpc5NUqtKsoXiIFfM5GJCFStzJ5aY1AGplc=,tag:gXWcgEXko4P7qMwVLGnF1Q==,type:str]
-NETDATA_PG_PASS=ENC[AES256_GCM,data:ylpbhrcFFKC5qiyt+RRPZhCWt098tPhGWxabgsO31tR7+g==,iv:O4b5DsNfCpqlhZ3GmXMRzp2/fUpMtLnhSqmoaD4+Q04=,tag:TG7ge49hjzkWcBO93hoXuA==,type:str]
-NETDATA_PG_ROLE=ENC[AES256_GCM,data:6/ESugRIiDgBM2e8,iv:kXJOKx7fbO4k9zV2APTXCxOrGJ/93dCkmVA33v9fzrY=,tag:HwEWzVagm6cDnPychaekaw==,type:str]
-ADMIN_EMAIL=ENC[AES256_GCM,data:AuhvA1iUOnMcE7el4TSE7cbLAg==,iv:eWNxWDA978OWQO10/ywc8CpKuUhC0uTqObmD5Tefgtc=,tag:pI4SXW1ZrZPthD2qRAPrIw==,type:str]
-ADMIN_INITIAL_PASSWORD=ENC[AES256_GCM,data:WiJm5VO57/qLu8fK0rZqFDk=,iv:2SwLMvMUjwkEDZo8VRtPHfs+cabz2L+9ZQAaWyn1o9c=,tag:+TunF3tuLxYvMeJsYNQuCQ==,type:str]
-sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBsNlZ6dWs5VnJXa0hrekdp\nYlpzYll6NUFnTnV0Sk0zbnB2WEw5alVwT2swCm82NmlUbjdabEJ2d1pHTGhYRG1m\nZmIyUHFQcVhvZHNzRTJuZVNqYThaWGMKLS0tIGtHTEhzQnh2SFVxdlBvRlpMblVW\nOWdVY0ErK2pVSzdtckc4Y0lPRTdrdmsKv5M0ojCoW5SQhnjXY116SmjvyCtSnehg\nQqtL6jElOv4MeLASHwYLYzznU6dxkZK3OKvcLh6mu+41Pnbl8u26yw==\n-----END AGE ENCRYPTED FILE-----\n
+HCLOUD_TOKEN=ENC[AES256_GCM,data:SZMlHgX2keD98hk2VU5G3aGdla3MsaSR4BqnCM4F9fNp3DNdx9zQyiRJ8LDBi62HUPBBubj1YSnUPnJFJ5bXtv6A,iv:mVor150MZPe7jMHEBtcXcoT434bAX1t7WSTLOG/hexY=,tag:DKWHOSg61vG/7qKsMDz+uw==,type:str]
+HETZNER_TOKEN=ENC[AES256_GCM,data:oe8Kp7a/j3PRc8IUnU0SUtu6zr4nqdUYWeR/TbmYz/vRoz5egDqbqi7SXi2GC0YD/JATMNiHL1ec5tYWPnceaKNS,iv:pm3IBAQSEEMqELno/hAFZgROXDixJdKv5GqEbAqSwMk=,tag:QLU06Q1D05pWSyQkKV2q3g==,type:str]
+JOKER_DYNDNS_USERNAME=ENC[AES256_GCM,data:jQEKhX2ISB9FlwMnnrp7oZ4D,iv:9nnUG5nD7DzPAAYiVDNd4C64YTgF9BSnq0+/WajJPVA=,tag:HLYprQoKRkTtaB82mPLnbQ==,type:str]
+JOKER_DYNDNS_PASSWORD=ENC[AES256_GCM,data:LUHbGwOLq+A3CsjYJ4iu9vCn,iv:Phl3sv3hCe8wRWX3TqlyeBsqhKiu2bvJF8M6f1ESnHY=,tag:lmQIynYBEl7jVg3pG/TOAg==,type:str]
+REGISTRY_USERNAME=ENC[AES256_GCM,data:IJKWI15AROA=,iv:crKgYBH39sKioPVNw69fv9H5NCGadc6X9DebqUIyC4M=,tag:7PBRGErLYdLi+ku/+U+9UA==,type:str]
+REGISTRY_PASSWORD=ENC[AES256_GCM,data:viuSD5MpNuGc,iv:/2Rcuv2cecP8iECeUfK/NowP3Fv2p+EPyqkCJoLQZnw=,tag:BApcmBr3jR+NoQ3ZqRaI8w==,type:str]
+NETDATA_USER=ENC[AES256_GCM,data:T51yECIfawfQ,iv:9q/drpZy7C6KzscGB+Lxgu0Rsg4Ov9qgvmUt2lfYfAE=,tag:XS1rN2wy3OAizpA6Ji5FhQ==,type:str]
+NETDATA_PASS=ENC[AES256_GCM,data:5j/hxKa4+9ovvaVqJdRWOns7cY2nG+nI5f5p3nkm,iv:/59+59bmpVv0XaeJrVOcLzS6sPEi3XTf3eNiwmsZFCI=,tag:1GrKhx0Zp0IztBtNpzVmxQ==,type:str]
+NETDATA_HOST_NODE1=ENC[AES256_GCM,data:9k1sqAJV2bLEStTC2L8KZ9OryFwLhDw=,iv:VWKvsEjOXBtfYHpiNG9wBX4HqG8i3XGW9wK6uOM0qbU=,tag:qKqYvnC0InnK+f3c7O3iTg==,type:str]
+NETDATA_HOST_DB1=ENC[AES256_GCM,data:ijCscUEJoup0CHh2vMtTTGzvkMtH,iv:taAvhzY9rpIgsdyR/B30Zo405l0lKO6/BvtAILwUWKA=,tag:ogOvtm7+y7R1TW82BfMWHQ==,type:str]
+NETDATA_PG_USER=ENC[AES256_GCM,data:ss8KoNmpRKk/,iv:kTHJyi4YpE+peQmXobd5jTFYrnhOWqypbnhsr251l+o=,tag:zTxRwUoqySysstFjOhF/NA==,type:str]
+NETDATA_PG_PASS=ENC[AES256_GCM,data:NEENSnAkLmBY7LxTzoqjNtG9YCFAdvF2yOj2wkzBvKbZew==,iv:7ZJNwLldd3i0BhO3uujzflhR01qDT9gDcqVtsUOXubg=,tag:rukJnDEItfa4csPxN3tvPg==,type:str]
+NETDATA_PG_ROLE=ENC[AES256_GCM,data:luPLkyzGXTf/MuYl,iv:VZf1TZlQzNkPqrFkdZhSQ/D+q9MTgz0jW4/88+4X3rs=,tag:ngpPhPvdad5U1ia2h0VMQw==,type:str]
+ADMIN_EMAIL=ENC[AES256_GCM,data:YUW3FIusETg/uRJQaP6ZCWjL1w==,iv:ibEncZK+qUsuNBXra6CxAaQQF4Oi9K4msaiOGggad7I=,tag:Sd+9MgTRx8/WA7GusZEuFg==,type:str]
+ADMIN_INITIAL_PASSWORD=ENC[AES256_GCM,data:VXiQWltYpxkts/xLNA4p8F8=,iv:as8Uq4S05e8peTP9phv5otdbyb7uyQe5gJ/ig7VKC2E=,tag:gsXXzVtft6AQBjQgrrhfng==,type:str]
+REDMINE_API_USER="lomavuokraus-bot"
+REDMINE_API_KEY="1826930a88e3732444c55efa5ea3b9ad6a7aeaff"
+REDMINE_URL="https://redmine.halla-aho.net"
+REDMINE_PROJECT_ID="1"
+REDMINE_TRACKER_BUG_ID="1"
+REDMINE_TRACKER_SECURITY_ID="1"
+sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBMQURYZEQ0SzhGV3NnaE1Z\nYWxucXdSY2FiT2Z1NnRIdlJmYkplRG11NVRRCmZab2VSalB5YjA3SjRYTi80dWhC\nbzVtYTlYeGxMaURHc3NueEg1ZWVjM0EKLS0tIFh3WWhRMXJQaHBCbWlhdEZrV0Fi\neTNZclVSZ3hhcGttWk53UFRIUGZLNncKXF7cGtBMBDVuFN2Y1lpN5hrbZacBOjvI\nUdq1/P3dOSFD3ciQBslZXgRJnK/hAQuP+f1RJ7KUwB0GvLXIiDQs6g==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_0__map_recipient=age1hkehkc2rryjl975c2mg5cghmjr54n4wjshncl292h2eg5l394fhs4uydrh
sops_encrypted_regex=^(AUTH_SECRET|DATABASE_URL|DB_.*|APP_URL|SMTP_.*|DKIM_.*|AUTO_APPROVE_LISTINGS|OPENAI_.*|H(ETZNER|CLOUD)_TOKEN|JOKER_DYNDNS_.*|REGISTRY_.*|NETDATA_.*|ADMIN_.*)$
-sops_lastmodified=2025-12-11T11:36:56Z
-sops_mac=ENC[AES256_GCM,data:bnDwxj/t2X+vkq1nd2Bej23GBn3hALXW6PAp4FyoAlvwajztp9U2eyF7voLQDeX1kurVBuACPExzzMnerEXOebF9l5SGcIYfvtVj9kk4I0WRCbVBt/QKgEtqYJ3l1TXrDe8ZPTj6O2rK6WW36RDExFDu3tzzvVEaHErZMjAhD1U=,iv:hBwrHOvabZEqeWHSFGvk5sHYbJiF6/3wY1JXgaevB9w=,tag:2fZQ9AQoHQJ+zte2yLpi5g==,type:str]
+sops_lastmodified=2025-12-14T12:06:30Z
+sops_mac=ENC[AES256_GCM,data:R06Pa93hx3HOijkBKdgm+MgBMVgkAoMS2wzZe55E1b5TlHLfTaa8DuqBNHkor/bPsJW584ByO0AA3DiLkeCLgo9yMulYhuizRIpzKsOI2FH5KcrTGOxj1h5Wxno0ToRoXNtgy71s2aDf7hmzLQhGetAOsmEl+HFy1DMs2dauU5Q=,iv:jh4OLLqTG+eBac6MmwR0CXN4irX+QZZbeTUcrQUOyQ8=,tag:ViE3djLoGcrbqMJgD9r3SQ==,type:str]
sops_version=3.11.0
diff --git a/docs/index.html b/docs/index.html
index 509e49b..965d19f 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -19,6 +19,7 @@
Logical Architecture
Feature Sequences
Security Testing
+ Redmine Integration
diff --git a/docs/redmine.html b/docs/redmine.html
new file mode 100644
index 0000000..48b4836
--- /dev/null
+++ b/docs/redmine.html
@@ -0,0 +1,50 @@
+
+
+
+
+ Redmine Integration
+
+
+
+
+
+
+ Setup
+
+ Env vars (see .env.example): REDMINE_URL, REDMINE_API_KEY, REDMINE_PROJECT_ID, REDMINE_TRACKER_BUG_ID, REDMINE_TRACKER_SECURITY_ID (optional, falls back to bug), REDMINE_ASSIGNEE_ID (optional default owner).
+ Uses the Redmine REST API with the API key for authentication.
+ Ensure the API key is scoped to the project and can create issues.
+
+
+
+
+ Automatic tickets on failures
+
+ scripts/run-test-suite.sh now files a Redmine issue whenever any check fails. Security-related failures (npm audit, Trivy, ZAP) use the security tracker when configured.
+ Issues are de-duplicated by fingerprinting the suite/target + failure details; reruns of the same failing state will re-use the open ticket instead of creating a duplicate.
+ Manual wrapper for other test commands: ./scripts/run-tests-with-redmine.sh npm test (set TEST_NAME or TRACKER to override labels).
+
+
+
+
+ CLI tools
+
+ List open tickets grouped by tracker: REDMINE_URL=... REDMINE_API_KEY=... REDMINE_PROJECT_ID=... ./scripts/redmine-report.js list-open
+ Manual issue creation from a log file: ./scripts/redmine-report.js create-test-issue --suite my-tests --failures-file /path/to/log --tracker bug
+ Outputs go to stdout; non-zero exit code on API errors so CI can fail fast.
+
+
+
+
+ Notes
+
+ Tickets include a short fingerprint in the subject and description; keep it when editing so future runs keep de-duplicating.
+ Summary/log paths are included in the issue body to help locate artifacts from the run.
+
+
+
+
+
diff --git a/scripts/load-secrets.sh b/scripts/load-secrets.sh
index 87276b7..c78c5ad 100644
--- a/scripts/load-secrets.sh
+++ b/scripts/load-secrets.sh
@@ -7,6 +7,8 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SECRETS_FILE="${SECRETS_FILE:-$ROOT_DIR/creds/secrets.env}"
ENCRYPTED_FILE="${ENCRYPTED_FILE:-$ROOT_DIR/creds/secrets.enc.env}"
+KUBECONFIG_FILE="${KUBECONFIG_FILE:-$ROOT_DIR/creds/kubeconfig.yaml}"
+KUBECONFIG_ENC_FILE="${KUBECONFIG_ENC_FILE:-$ROOT_DIR/creds/kubeconfig.enc.yaml}"
ensure_decrypted() {
if [[ -f "$SECRETS_FILE" ]]; then
@@ -24,8 +26,33 @@ ensure_decrypted() {
}
ensure_decrypted || exit 0
-
echo "Loading secrets from $SECRETS_FILE"
+
set -a
source "$SECRETS_FILE"
set +a
+
+ensure_kubeconfig() {
+ # If user already set KUBECONFIG, respect it.
+ if [[ -n "${KUBECONFIG:-}" ]]; then
+ return 0
+ fi
+
+ if [[ -f "$KUBECONFIG_FILE" ]]; then
+ export KUBECONFIG="$KUBECONFIG_FILE"
+ return 0
+ fi
+
+ if [[ -f "$KUBECONFIG_ENC_FILE" ]]; then
+ if command -v sops >/dev/null 2>&1; then
+ echo "Decrypting $KUBECONFIG_ENC_FILE -> $KUBECONFIG_FILE"
+ sops -d "$KUBECONFIG_ENC_FILE" >"$KUBECONFIG_FILE"
+ export KUBECONFIG="$KUBECONFIG_FILE"
+ else
+ echo "sops not found and kubeconfig is missing. Install sops or set KUBECONFIG manually." >&2
+ return 1
+ fi
+ fi
+}
+
+ensure_kubeconfig || true
diff --git a/scripts/redmine-report.js b/scripts/redmine-report.js
new file mode 100755
index 0000000..cfdca10
--- /dev/null
+++ b/scripts/redmine-report.js
@@ -0,0 +1,297 @@
+#!/usr/bin/env node
+const fs = require('fs');
+const crypto = require('crypto');
+
+const [command, ...argv] = process.argv.slice(2);
+
+const parseArgs = (args) => {
+ const parsed = {};
+ let i = 0;
+ while (i < args.length) {
+ const arg = args[i];
+ if (arg.startsWith('--')) {
+ const key = arg.replace(/^--/, '');
+ const next = args[i + 1];
+ if (next && !next.startsWith('--')) {
+ parsed[key] = next;
+ i += 2;
+ } else {
+ parsed[key] = true;
+ i += 1;
+ }
+ } else {
+ parsed._ = parsed._ || [];
+ parsed._.push(arg);
+ i += 1;
+ }
+ }
+ return parsed;
+};
+
+const args = parseArgs(argv);
+
+const ensureEnv = (key, optional = false) => {
+ const value = process.env[key];
+ if (!value && !optional) {
+ throw new Error(`Missing required env: ${key}`);
+ }
+ return value;
+};
+
+const readConfig = () => {
+ const baseUrl = ensureEnv('REDMINE_URL');
+ const url = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
+ return {
+ url,
+ apiKey: ensureEnv('REDMINE_API_KEY'),
+ projectId: ensureEnv('REDMINE_PROJECT_ID'),
+ trackerBugId: ensureEnv('REDMINE_TRACKER_BUG_ID'),
+ trackerSecurityId: process.env.REDMINE_TRACKER_SECURITY_ID,
+ assigneeId: process.env.REDMINE_ASSIGNEE_ID,
+ };
+};
+
+const redmineUrl = (config, path, params = {}) => {
+ const url = new URL(path.replace(/^\//, ''), config.url);
+ Object.entries(params).forEach(([key, value]) => {
+ if (value !== undefined && value !== null && value !== '') {
+ url.searchParams.set(key, value);
+ }
+ });
+ return url;
+};
+
+const fetchJson = async (config, path, options = {}, params = {}) => {
+ const url = redmineUrl(config, path, params);
+ const res = await fetch(url, {
+ headers: {
+ 'X-Redmine-API-Key': config.apiKey,
+ 'Content-Type': 'application/json',
+ },
+ ...options,
+ });
+
+ if (!res.ok) {
+ const text = await res.text();
+ throw new Error(`Redmine request failed (${res.status}): ${text}`);
+ }
+
+ if (res.status === 204) {
+ return {};
+ }
+
+ return res.json();
+};
+
+const fetchAllOpenIssues = async (config) => {
+ const issues = [];
+ const limit = 100;
+ let offset = 0;
+ let total = null;
+
+ while (total === null || offset < total) {
+ const data = await fetchJson(
+ config,
+ '/issues.json',
+ {},
+ {
+ project_id: config.projectId,
+ status_id: 'open',
+ limit,
+ offset,
+ sort: 'updated_on:desc',
+ },
+ );
+ if (Array.isArray(data.issues)) {
+ issues.push(...data.issues);
+ }
+ total = data.total_count ?? issues.length;
+ offset += limit;
+ if (!data.issues || data.issues.length === 0) {
+ break;
+ }
+ }
+
+ return issues;
+};
+
+const findExistingIssue = async (config, fingerprint) => {
+ if (!fingerprint) return null;
+ const issues = await fetchAllOpenIssues(config);
+ return issues.find(
+ (issue) =>
+ issue.subject?.includes(fingerprint) ||
+ issue.description?.includes(`Fingerprint: ${fingerprint}`),
+ );
+};
+
+const computeFingerprint = (seed) => {
+ const hash = crypto.createHash('sha1').update(seed).digest('hex');
+ return hash.slice(0, 12);
+};
+
+const readFailures = (opts) => {
+ const failures = [];
+ if (opts['failures-file']) {
+ try {
+ const text = fs.readFileSync(opts['failures-file'], 'utf8');
+ text
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .forEach((line) => failures.push(line));
+ } catch (err) {
+ console.error(`Could not read failures file: ${err.message}`);
+ }
+ }
+
+ if (opts.failures) {
+ failures.push(opts.failures);
+ }
+
+ if (failures.length === 0) {
+ failures.push('Test failure (no details provided)');
+ }
+
+ return failures;
+};
+
+const createIssue = async (config, payload) => {
+ const body = {
+ issue: {
+ project_id: config.projectId,
+ tracker_id: payload.trackerId,
+ subject: payload.subject,
+ description: payload.description,
+ },
+ };
+
+ if (config.assigneeId) {
+ body.issue.assigned_to_id = config.assigneeId;
+ }
+
+ const res = await fetchJson(config, '/issues.json', {
+ method: 'POST',
+ body: JSON.stringify(body),
+ });
+ return res.issue || res;
+};
+
+const handleCreateTestIssue = async () => {
+ const config = readConfig();
+ const suite = args.suite || 'tests';
+ const tracker = (args.tracker || 'bug').toLowerCase();
+ const trackerId =
+ tracker === 'security'
+ ? config.trackerSecurityId || config.trackerBugId
+ : config.trackerBugId;
+
+ if (!trackerId) {
+ throw new Error('Missing tracker id. Set REDMINE_TRACKER_BUG_ID (and optionally REDMINE_TRACKER_SECURITY_ID).');
+ }
+
+ const failCount = Number(args['fail-count'] || args.failcount || 0);
+ const failures = readFailures(args);
+ const fingerprintSeed =
+ args['fingerprint-seed'] ||
+ `${suite}|${args.target || ''}|${failCount}|${failures.join('|')}`;
+ const fingerprint = args.fingerprint || computeFingerprint(fingerprintSeed);
+ const subject = `[${suite}] ${failCount || failures.length} failure${failCount === 1 ? '' : 's'} (${tracker}) [${fingerprint}]`;
+
+ const descriptionLines = [
+ `Suite: ${suite}`,
+ args.run ? `Run: ${args.run}` : null,
+ args.target ? `Target: ${args.target}` : null,
+ `Tracker: ${tracker}`,
+ `Failures (${failures.length}):`,
+ ...failures.map((line) => `- ${line}`),
+ args['summary-file'] ? `Summary: ${args['summary-file']}` : null,
+ args['failures-file'] ? `Log: ${args['failures-file']}` : null,
+ `Fingerprint: ${fingerprint}`,
+ ].filter(Boolean);
+
+ const existing = await findExistingIssue(config, fingerprint);
+ if (existing) {
+ console.log(
+ `Open issue #${existing.id} already exists for fingerprint ${fingerprint}; not creating a duplicate.`,
+ );
+ return;
+ }
+
+ const created = await createIssue(config, {
+ trackerId,
+ subject,
+ description: descriptionLines.join('\n'),
+ });
+
+ console.log(`Created Redmine issue #${created.id}: ${subject}`);
+};
+
+const handleListOpen = async () => {
+ const config = readConfig();
+ const issues = await fetchAllOpenIssues(config);
+ if (!issues.length) {
+ console.log('No open issues found for the configured project.');
+ return;
+ }
+
+ const groups = issues.reduce((acc, issue) => {
+ const tracker = issue.tracker?.name || 'Unknown';
+ acc[tracker] = acc[tracker] || [];
+ acc[tracker].push(issue);
+ return acc;
+ }, {});
+
+ Object.entries(groups).forEach(([trackerName, trackerIssues]) => {
+ console.log(`${trackerName} (${trackerIssues.length})`);
+ trackerIssues.forEach((issue) => {
+ const status = issue.status?.name ? ` [${issue.status.name}]` : '';
+ const priority = issue.priority?.name ? ` (${issue.priority.name})` : '';
+ console.log(`- #${issue.id}${status}${priority}: ${issue.subject}`);
+ });
+ console.log('');
+ });
+};
+
+const printHelp = () => {
+ console.log(`Usage:
+ redmine-report.js create-test-issue --suite --run --fail-count --failures-file [--target ] [--tracker bug|security] [--fingerprint ]
+ redmine-report.js list-open
+
+Env:
+ REDMINE_URL Base URL, e.g. https://redmine.example.com
+ REDMINE_API_KEY API key
+ REDMINE_PROJECT_ID Project to file against
+ REDMINE_TRACKER_BUG_ID Tracker id for bugs
+ REDMINE_TRACKER_SECURITY_ID Tracker id for security issues (optional)
+ REDMINE_ASSIGNEE_ID Default assignee (optional)
+`);
+};
+
+const main = async () => {
+ try {
+ switch (command) {
+ case 'create-test-issue':
+ await handleCreateTestIssue();
+ break;
+ case 'list-open':
+ await handleListOpen();
+ break;
+ case '-h':
+ case '--help':
+ case undefined:
+ printHelp();
+ process.exit(command ? 0 : 1);
+ break;
+ default:
+ console.error(`Unknown command: ${command}`);
+ printHelp();
+ process.exit(1);
+ }
+ } catch (err) {
+ console.error(`Redmine command failed: ${err.message}`);
+ process.exit(1);
+ }
+};
+
+main();
diff --git a/scripts/run-test-suite.sh b/scripts/run-test-suite.sh
index feb7779..51db518 100755
--- a/scripts/run-test-suite.sh
+++ b/scripts/run-test-suite.sh
@@ -97,6 +97,49 @@ ${items:-" No runs found. "}
EOF
}
+notify_redmine() {
+ local fail_lines=()
+ local failures_file="$RUN_DIR/failures.txt"
+
+ for row in "${SUMMARY_TEXT_ROWS[@]}"; do
+ if [[ "$row" == *"FAIL"* ]]; then
+ fail_lines+=("$row")
+ fi
+ done
+
+ [ "${#fail_lines[@]}" -gt 0 ] || return
+
+ printf "%s\n" "${fail_lines[@]}" >"$failures_file"
+
+ local tracker="bug"
+ for row in "${fail_lines[@]}"; do
+ case "$row" in
+ npm\ audit:*) tracker="security" ;;
+ Trivy*) tracker="security" ;;
+ OWASP\ ZAP\ baseline*) tracker="security" ;;
+ esac
+ done
+
+ if command -v node >/dev/null 2>&1 && [ -x "${BASH_SOURCE%/*}/redmine-report.js" ]; then
+ log "Reporting failures to Redmine (${tracker})..."
+ if node "${BASH_SOURCE%/*}/redmine-report.js" create-test-issue \
+ --suite "run-test-suite" \
+ --run "$RUN_TS" \
+ --fail-count "$FAIL_COUNT" \
+ --failures-file "$failures_file" \
+ --summary-file "$SUMMARY_FILE" \
+ --target "$TARGET" \
+ --tracker "$tracker" \
+ --fingerprint-seed "${TARGET}|${fail_lines[*]}"; then
+ log "Redmine notification complete."
+ else
+ log "Redmine notification failed or skipped (see output above)."
+ fi
+ else
+ log "Redmine reporter not available; skipping Redmine notification."
+ fi
+}
+
# 1) npm audit
if command -v npm >/dev/null 2>&1; then
log "Running npm audit (high)..."
@@ -192,8 +235,8 @@ cat >"$SUMMARY_FILE" <Check Status Details
${SUMMARY_ROWS[*]}
-
-
+
+