feat(wiki): deployable Wiki.js config job (#0049) — headless setup + OIDC + roles
Idempotent GraphQL bootstrap job (verified live): /finalize with the random SOPS admin, then the OIDC strategy and the groups 'authentik Admins' (full) and 'wiki-anwender' (read /anwender), Guests locked. Replaces the manual setup wizard entirely. OIDC client_id/secret in a SOPS secret; NetworkPolicy lets the job reach wikijs. Script as a fixed-name ConfigMap; re-run = delete the Job.
This commit is contained in:
@@ -56,9 +56,11 @@ resources:
|
||||
# Wiki.js (Plattform-Wiki, ADR-0014, #0048)
|
||||
- wikijs-postgres-secret.yaml # SOPS, von sorb angelegt
|
||||
- wikijs-admin-secret.yaml # SOPS, randomisiert — Bootstrap durch den Konfig-Job
|
||||
- wikijs-oidc-secret.yaml # SOPS, client_id/secret für die OIDC-Strategy
|
||||
- wikijs-postgres.yaml
|
||||
- wikijs.yaml
|
||||
- wiki-ingress.yaml
|
||||
- wikijs-config.yaml # Konfig-Job (headless Setup + OIDC + Rollen)
|
||||
|
||||
# Synapse-Modul als eigene Datei gepflegt (lintbar/testbar), aber als ConfigMap gemounted -
|
||||
# disableNameSuffixHash, da der Name in synapse-values.yaml's eingebettetem values.yaml
|
||||
@@ -79,3 +81,9 @@ configMapGenerator:
|
||||
- clamav_spam_checker.py
|
||||
options:
|
||||
disableNameSuffixHash: true
|
||||
- name: wikijs-config-script
|
||||
namespace: matrix
|
||||
files:
|
||||
- wikijs-config.py
|
||||
options:
|
||||
disableNameSuffixHash: true
|
||||
|
||||
@@ -377,6 +377,9 @@ spec:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: kube-system
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: wikijs-config # Konfig-Job darf Wiki.js erreichen
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: http
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env python3
|
||||
"""wikijs-config.py — idempotenter Bootstrap + Konfiguration von Wiki.js.
|
||||
|
||||
Deploybares Äquivalent des manuellen Setup-Assistenten (ADR-0014, #0048/#0049).
|
||||
Läuft als k8s-Job, nachdem Wiki.js oben ist; treibt die Admin-GraphQL-API. Nur
|
||||
stdlib. Alle Verträge wurden am 2026-08-12 live gegen die laufende Instanz geprüft.
|
||||
|
||||
Ablauf:
|
||||
warten -> (falls Setup) /finalize mit SOPS-Admin -> warten auf Normalmodus
|
||||
-> login -> OIDC-Strategy setzen -> Gruppen + Seitenregeln (Abschottung).
|
||||
Idempotent: jeder Schritt prüft erst den Ist-Zustand.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
WIKI = os.environ.get("WIKI_URL", "http://wikijs:3000")
|
||||
ADMIN_EMAIL = os.environ["WIKI_ADMIN_EMAIL"]
|
||||
ADMIN_PW = os.environ["WIKI_ADMIN_PASSWORD"]
|
||||
SITE_URL = os.environ.get("WIKI_SITE_URL", "https://wiki.axion1337.chat")
|
||||
CLIENT_ID = os.environ["OIDC_CLIENT_ID"]
|
||||
CLIENT_SECRET = os.environ["OIDC_CLIENT_SECRET"]
|
||||
AUTH = os.environ.get("AUTHENTIK_URL", "https://auth.axion1337.chat")
|
||||
APP_SLUG = os.environ.get("OIDC_APP_SLUG", "wiki-js")
|
||||
# Der Strategy-Key bestimmt die Callback-URL (/login/<key>/callback) und MUSS mit
|
||||
# der redirect_uri im Authentik-Provider übereinstimmen.
|
||||
STRATEGY_KEY = os.environ.get("OIDC_STRATEGY_KEY", "d3e7d0e4-adff-4421-b016-7758c44fd697")
|
||||
|
||||
ADMIN_PERMS = [
|
||||
"manage:system", "manage:users", "manage:groups", "manage:navigation",
|
||||
"manage:pages", "write:pages", "read:pages", "manage:assets", "write:assets",
|
||||
"read:assets", "manage:comments", "write:comments", "read:comments",
|
||||
"read:history", "read:source", "write:styles", "write:scripts",
|
||||
"manage:theme", "manage:api",
|
||||
]
|
||||
READER_PERMS = ["read:pages", "read:assets", "read:comments"]
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
print(f"[wikijs-config] {msg}", flush=True)
|
||||
|
||||
|
||||
def req(path: str, data=None, headers=None, method=None):
|
||||
body = json.dumps(data).encode() if data is not None else None
|
||||
r = urllib.request.Request(WIKI + path, data=body, method=method or ("POST" if body else "GET"))
|
||||
r.add_header("Content-Type", "application/json")
|
||||
for k, v in (headers or {}).items():
|
||||
r.add_header(k, v)
|
||||
with urllib.request.urlopen(r, timeout=30) as resp:
|
||||
raw = resp.read().decode()
|
||||
return resp.status, (json.loads(raw) if raw else {})
|
||||
|
||||
|
||||
def gql(query: str, jwt: str | None = None, variables: dict | None = None):
|
||||
h = {"Authorization": f"Bearer {jwt}"} if jwt else {}
|
||||
_, d = req("/graphql", {"query": query, "variables": variables or {}}, h)
|
||||
if d.get("errors"):
|
||||
raise RuntimeError(json.dumps(d["errors"]))
|
||||
return d["data"]
|
||||
|
||||
|
||||
def wait_reachable():
|
||||
# Startseite ist HTML, nicht JSON -> nicht über req()/json.loads prüfen.
|
||||
for _ in range(60):
|
||||
try:
|
||||
with urllib.request.urlopen(WIKI + "/", timeout=10) as r:
|
||||
if r.status < 500:
|
||||
return
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code < 500:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(3)
|
||||
sys.exit("Wiki.js nicht erreichbar")
|
||||
|
||||
|
||||
def in_setup_mode() -> bool:
|
||||
# Normalmodus: die (öffentliche) login-Mutation ist beantwortbar. Setup-Modus:
|
||||
# das Haupt-Schema ist nicht geladen -> die Mutation wirft.
|
||||
try:
|
||||
gql('mutation{authentication{login(username:"probe@invalid",password:"x",'
|
||||
'strategy:"local"){responseResult{succeeded}}}}')
|
||||
return False
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
def finalize():
|
||||
log("Setup-Modus -> finalize mit SOPS-Admin")
|
||||
_, d = req("/finalize", {
|
||||
"adminEmail": ADMIN_EMAIL,
|
||||
"adminPassword": ADMIN_PW,
|
||||
"adminPasswordConfirm": ADMIN_PW,
|
||||
"siteUrl": SITE_URL,
|
||||
"telemetry": False,
|
||||
})
|
||||
if not d.get("ok"):
|
||||
sys.exit(f"finalize fehlgeschlagen: {d}")
|
||||
log("finalize ok, warte auf Normalmodus (Neustart)")
|
||||
time.sleep(10)
|
||||
for _ in range(40):
|
||||
if not in_setup_mode():
|
||||
return
|
||||
time.sleep(3)
|
||||
sys.exit("Normalmodus nicht erreicht")
|
||||
|
||||
|
||||
def login() -> str:
|
||||
d = gql(
|
||||
'mutation($u:String!,$p:String!){authentication{login(username:$u,password:$p,'
|
||||
'strategy:"local"){responseResult{succeeded message} jwt}}}',
|
||||
variables={"u": ADMIN_EMAIL, "p": ADMIN_PW},
|
||||
)["authentication"]["login"]
|
||||
if not d["responseResult"]["succeeded"]:
|
||||
sys.exit(f"Login fehlgeschlagen: {d['responseResult']['message']}")
|
||||
return d["jwt"]
|
||||
|
||||
|
||||
def kv(d: dict) -> list:
|
||||
return [{"key": k, "value": json.dumps({"value": v})} for k, v in d.items()]
|
||||
|
||||
|
||||
def ensure_oidc(jwt: str):
|
||||
cfg = {
|
||||
"clientId": CLIENT_ID, "clientSecret": CLIENT_SECRET,
|
||||
"authorizationURL": f"{AUTH}/application/o/authorize/",
|
||||
"tokenURL": f"{AUTH}/application/o/token/",
|
||||
"userInfoURL": f"{AUTH}/application/o/userinfo/",
|
||||
"issuer": f"{AUTH}/application/o/{APP_SLUG}/",
|
||||
"logoutURL": f"{AUTH}/application/o/{APP_SLUG}/end-session/",
|
||||
"emailClaim": "email", "displayNameClaim": "name",
|
||||
"groupsClaim": "groups", "mapGroups": True,
|
||||
"skipUserProfile": False,
|
||||
}
|
||||
strategies = [
|
||||
{ # local muss in der Liste bleiben (Admin nutzt es), sonst Fehler.
|
||||
"key": "local", "strategyKey": "local", "displayName": "Local",
|
||||
"order": 0, "isEnabled": True, "selfRegistration": False,
|
||||
"domainWhitelist": [], "autoEnrollGroups": [], "config": [],
|
||||
},
|
||||
{
|
||||
"key": STRATEGY_KEY, "strategyKey": "oidc", "displayName": "Authentik",
|
||||
"order": 1, "isEnabled": True, "selfRegistration": True,
|
||||
"domainWhitelist": [], "autoEnrollGroups": [], "config": kv(cfg),
|
||||
},
|
||||
]
|
||||
r = gql(
|
||||
'mutation($s:[AuthenticationStrategyInput]!){authentication{updateStrategies(strategies:$s)'
|
||||
'{responseResult{succeeded message}}}}',
|
||||
jwt, {"s": strategies},
|
||||
)["authentication"]["updateStrategies"]["responseResult"]
|
||||
if not r["succeeded"]:
|
||||
sys.exit(f"OIDC-Strategy fehlgeschlagen: {r['message']}")
|
||||
log("OIDC-Strategy gesetzt")
|
||||
|
||||
|
||||
def group_id(jwt: str, name: str):
|
||||
for g in gql("{groups{list{id name}}}", jwt)["groups"]["list"]:
|
||||
if g["name"] == name:
|
||||
return g["id"]
|
||||
return None
|
||||
|
||||
|
||||
def ensure_group(jwt: str, name: str, perms: list, rules: list):
|
||||
gid = group_id(jwt, name)
|
||||
if gid is None:
|
||||
gql('mutation($n:String!){groups{create(name:$n){responseResult{succeeded message}}}}',
|
||||
jwt, {"n": name})
|
||||
gid = group_id(jwt, name)
|
||||
log(f"Gruppe '{name}' angelegt (id {gid})")
|
||||
gql(
|
||||
'mutation($id:Int!,$n:String!,$p:[String]!,$r:[PageRuleInput]!){groups{update('
|
||||
'id:$id,name:$n,redirectOnLogin:"/",permissions:$p,pageRules:$r){responseResult{succeeded message}}}}',
|
||||
jwt, {"id": gid, "n": name, "p": perms, "r": rules},
|
||||
)
|
||||
log(f"Gruppe '{name}' -> Rechte+Seitenregeln gesetzt")
|
||||
|
||||
|
||||
def rule(rid: str, deny: bool, perms: list, path: str):
|
||||
return {"id": rid, "deny": deny, "match": "START", "roles": perms,
|
||||
"path": path, "locales": []}
|
||||
|
||||
|
||||
def main():
|
||||
log(f"Ziel: {WIKI}")
|
||||
wait_reachable()
|
||||
if in_setup_mode():
|
||||
finalize()
|
||||
jwt = login()
|
||||
log("eingeloggt")
|
||||
ensure_oidc(jwt)
|
||||
# authentik Admins: alles lesen+schreiben. wiki-anwender: nur /anwender lesen.
|
||||
ensure_group(jwt, "authentik Admins", ADMIN_PERMS, [rule("adm", False, ["read:pages", "write:pages", "manage:pages"], "")])
|
||||
ensure_group(jwt, "wiki-anwender", READER_PERMS, [rule("anw", False, READER_PERMS, "anwender")])
|
||||
# Guests (id 2) alle Rechte entziehen — Login-Pflicht, keine öffentliche Sicht.
|
||||
gql('mutation{groups{update(id:2,name:"Guests",redirectOnLogin:"/",permissions:[],pageRules:[]){responseResult{succeeded}}}}', jwt)
|
||||
log("fertig — Wiki.js konfiguriert")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,54 @@
|
||||
# Bootstrap-/Konfig-Job für Wiki.js (ADR-0014, #0048/#0049). Ersetzt den manuellen
|
||||
# Setup-Assistenten: finalize (Admin aus SOPS) -> OIDC-Strategy -> Gruppen +
|
||||
# Seitenregeln. Idempotent (live verifiziert 2026-08-12). Läuft einmal beim Deploy;
|
||||
# erneut anstoßen = Job löschen, Flux legt ihn neu an (Skript ist re-runnable).
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: wikijs-config
|
||||
namespace: matrix
|
||||
spec:
|
||||
backoffLimit: 10
|
||||
ttlSecondsAfterFinished: 86400
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: wikijs-config
|
||||
spec:
|
||||
restartPolicy: OnFailure
|
||||
containers:
|
||||
- name: config
|
||||
image: python:3.12-alpine
|
||||
command: ["python3", "/script/wikijs-config.py"]
|
||||
env:
|
||||
- name: WIKI_URL
|
||||
value: http://wikijs:3000
|
||||
- name: WIKI_SITE_URL
|
||||
value: https://wiki.axion1337.chat
|
||||
- name: WIKI_ADMIN_EMAIL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: wikijs-admin-secret
|
||||
key: email
|
||||
- name: WIKI_ADMIN_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: wikijs-admin-secret
|
||||
key: password
|
||||
- name: OIDC_CLIENT_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: wikijs-oidc-secret
|
||||
key: client_id
|
||||
- name: OIDC_CLIENT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: wikijs-oidc-secret
|
||||
key: client_secret
|
||||
volumeMounts:
|
||||
- name: script
|
||||
mountPath: /script
|
||||
volumes:
|
||||
- name: script
|
||||
configMap:
|
||||
name: wikijs-config-script
|
||||
@@ -0,0 +1,23 @@
|
||||
apiVersion: v1
|
||||
data:
|
||||
client_id: ENC[AES256_GCM,data:QhTMVvbKj+cNAgU7oXHsnyM3y5w88lo3HVf7eQ6a/RWlaVFcbwuNm6LFaeV2fyv+mUCFJ2HtLKc=,iv:9J90hdC728jAqJ4htRGeEnd0mcrQqF7VvzOGE4NKrzg=,tag:ACgs5IAgcoKFy7noFHZHoA==,type:str]
|
||||
client_secret: ENC[AES256_GCM,data:uix6U6Ve60OMUWU1+WsW5ueytELs890A3JJdCad6BoQvcrjnDq9a0z1dAzLjzY9BQqBRo8Bzyd7JBwZVM2EXiJ/cMGgTvNNmMsPlpWcWg4Wu3sUl3gl5UkszcBOlWVXx6BQ0IHSKFOZw7vQYZxFLc5fiCveD4QWwGj+5AMZyNCWJtaxVFDMpno5WFW0Ht2aBWwqHCTK0irXjfeCuxEubmlMd4zzdri5PVn1vlA==,iv:jA4qw1RVDIgk0irFMz744N8LQEM+2lI/nW8jtYXKgyk=,tag:7rrzGejQ025cRnQ+MSHSYA==,type:str]
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: wikijs-oidc-secret
|
||||
namespace: matrix
|
||||
sops:
|
||||
age:
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBYYVdFUXVOQmRqTEJ6Zlhx
|
||||
MG9aUmxaUGZYWjg5YmNYWVBZYUdIbEtGL1ZFCk9tUG1ZTGE4QzBOdzRzT0dkVy90
|
||||
VSsyaFNTK21hb0VlWVhvN0JjeTBKZ2cKLS0tIFB1cGVnZktsczRsQ0NGcmhZNWE3
|
||||
K0l2SUtFanc3UE1WRlo5TUk3OXRJVEkKWE+LvhI8MGNCy54ylmRujV+I6IywurDy
|
||||
DHO71yXy2xnwDfWEV7Mcg02D2lGgGfdFqpY9ulWuFqh/qDM6CMZ4ig==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age14l0hwfqylwpemz5y2ghh2yxk0phszlnj3qlejhue0fw0kz3tmfgqdsjzdh
|
||||
encrypted_regex: ^(data|stringData)$
|
||||
lastmodified: "2026-08-12T22:01:16Z"
|
||||
mac: ENC[AES256_GCM,data:m+FzQOlNKAzByFxtrC0PPzSsE7T3gDH2Y+OvBLHEl7mcxVoB376ZsRi7op2g456NlgxX/9kD0X+t33RYzqLMe0Q5b3jzQgYPV0SfsIqgpmfid6uhHxGhzpQWGfTIImx9MP2oQqqW93uicKYi64SxbVMcC2xo27gvRpeMTvEALR8=,iv:DrZs65lJ2iSbNtXr/p637Kf0Cxuxs1UfBpgE1kAEED8=,tag:cpzPBw2a9YfllWh8+HiPxg==,type:str]
|
||||
version: 3.13.3
|
||||
Reference in New Issue
Block a user