Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7be693bf37 | ||
|
|
9e9a3ea8cc | ||
|
|
35ba875380 | ||
|
|
3d7ecbcfe9 | ||
|
|
893c9dee4f |
No files matched your search
@@ -85,7 +85,7 @@ spec:
|
|||||||
- name: config
|
- name: config
|
||||||
mountPath: /config
|
mountPath: /config
|
||||||
- name: oauth2-proxy
|
- name: oauth2-proxy
|
||||||
image: quay.io/oauth2-proxy/oauth2-proxy:v7.8.1
|
image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.6
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
args:
|
args:
|
||||||
- --http-address=0.0.0.0:4180
|
- --http-address=0.0.0.0:4180
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ spec:
|
|||||||
- -c
|
- -c
|
||||||
- "# Assert the declared adlists in gravity.db. Idempotent: steady state writes nothing and\n# does NOT run a gravity rebuild.\nset -u\nGRAVITY=/etc/pihole/gravity.db\n\n# THE DECLARED SET \u2014 one URL per line, the source of truth for what PiHole blocks.\n# To add a list: add a line here, commit, let Flux reconcile. Nothing else to do.\nDECLARED='https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts'\n\n# On a fresh PVC, FTL creates gravity.db a moment after the container starts. Wait, bounded,\n# and NEVER block or fail the start: a missing DB here must not crashloop DNS for the LAN.\ni=0\nwhile [ ! -s \"$GRAVITY\" ] && [ \"$i\" -lt 90 ]; do sleep 2; i=$(expr $i + 1); done\nif [ ! -s \"$GRAVITY\" ]; then\n echo \"pihole-adlists: gravity.db absent after 180s - leaving adlists untouched\" >&2\n exit 0\nfi\n\nsql() { pihole-FTL sqlite3 \"$GRAVITY\" \"$1\"; }\nCHANGED=0\n\n# 1) Every declared URL must be present AND enabled.\necho \"$DECLARED\" | while IFS= read -r url; do\n [ -n \"$url\" ] || continue\n printf '%s\\n' \"$url\"\ndone > /tmp/declared.txt\nwhile IFS= read -r url; do\n [ -n \"$url\" ] || continue\n # Single quotes inside a URL would break this SQL; adlist URLs do not contain them.\n have=$(sql \"select count(*) from adlist where address='$url';\")\n if [ \"$have\" = \"0\" ]; then\n echo \"pihole-adlists: adding $url\"\n sql \"insert into adlist (address,enabled,comment) values ('$url',1,'declared in catalog/pihole.yaml');\"\n CHANGED=1\n else\n en=$(sql \"select enabled from adlist where address='$url';\")\n if [ \"$en\" != \"1\" ]; then\n echo \"pihole-adlists: re-enabling $url\"\n sql \"update adlist set enabled=1 where address='$url';\"\n CHANGED=1\n fi\n fi\ndone < /tmp/declared.txt\n\n# 2) Anything enabled in the DB but NOT declared gets disabled (never deleted - see above).\nsql \"select address from adlist where enabled=1;\" | while IFS= read -r addr; do\n [ -n \"$addr\" ] || continue\n if ! grep -qxF \"$addr\" /tmp/declared.txt; then\n echo \"pihole-adlists: disabling undeclared $addr\"\n sql \"update adlist set enabled=0 where address='$addr';\"\n echo changed >> /tmp/adlist-changed\n fi\ndone\n[ -s /tmp/adlist-changed ] && CHANGED=1\nrm -f /tmp/adlist-changed /tmp/declared.txt\n\n# 3) Rebuild gravity ONLY if the declared set actually moved.\nif [ \"$CHANGED\" = \"1\" ]; then\n echo \"pihole-adlists: declared set changed - running gravity update\"\n pihole -g || echo \"pihole-adlists: gravity update failed; existing gravity.db retained\" >&2\nelse\n echo \"pihole-adlists: already matches the declared set - nothing to do\"\nfi\nexit 0\n"
|
- "# Assert the declared adlists in gravity.db. Idempotent: steady state writes nothing and\n# does NOT run a gravity rebuild.\nset -u\nGRAVITY=/etc/pihole/gravity.db\n\n# THE DECLARED SET \u2014 one URL per line, the source of truth for what PiHole blocks.\n# To add a list: add a line here, commit, let Flux reconcile. Nothing else to do.\nDECLARED='https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts'\n\n# On a fresh PVC, FTL creates gravity.db a moment after the container starts. Wait, bounded,\n# and NEVER block or fail the start: a missing DB here must not crashloop DNS for the LAN.\ni=0\nwhile [ ! -s \"$GRAVITY\" ] && [ \"$i\" -lt 90 ]; do sleep 2; i=$(expr $i + 1); done\nif [ ! -s \"$GRAVITY\" ]; then\n echo \"pihole-adlists: gravity.db absent after 180s - leaving adlists untouched\" >&2\n exit 0\nfi\n\nsql() { pihole-FTL sqlite3 \"$GRAVITY\" \"$1\"; }\nCHANGED=0\n\n# 1) Every declared URL must be present AND enabled.\necho \"$DECLARED\" | while IFS= read -r url; do\n [ -n \"$url\" ] || continue\n printf '%s\\n' \"$url\"\ndone > /tmp/declared.txt\nwhile IFS= read -r url; do\n [ -n \"$url\" ] || continue\n # Single quotes inside a URL would break this SQL; adlist URLs do not contain them.\n have=$(sql \"select count(*) from adlist where address='$url';\")\n if [ \"$have\" = \"0\" ]; then\n echo \"pihole-adlists: adding $url\"\n sql \"insert into adlist (address,enabled,comment) values ('$url',1,'declared in catalog/pihole.yaml');\"\n CHANGED=1\n else\n en=$(sql \"select enabled from adlist where address='$url';\")\n if [ \"$en\" != \"1\" ]; then\n echo \"pihole-adlists: re-enabling $url\"\n sql \"update adlist set enabled=1 where address='$url';\"\n CHANGED=1\n fi\n fi\ndone < /tmp/declared.txt\n\n# 2) Anything enabled in the DB but NOT declared gets disabled (never deleted - see above).\nsql \"select address from adlist where enabled=1;\" | while IFS= read -r addr; do\n [ -n \"$addr\" ] || continue\n if ! grep -qxF \"$addr\" /tmp/declared.txt; then\n echo \"pihole-adlists: disabling undeclared $addr\"\n sql \"update adlist set enabled=0 where address='$addr';\"\n echo changed >> /tmp/adlist-changed\n fi\ndone\n[ -s /tmp/adlist-changed ] && CHANGED=1\nrm -f /tmp/adlist-changed /tmp/declared.txt\n\n# 3) Rebuild gravity ONLY if the declared set actually moved.\nif [ \"$CHANGED\" = \"1\" ]; then\n echo \"pihole-adlists: declared set changed - running gravity update\"\n pihole -g || echo \"pihole-adlists: gravity update failed; existing gravity.db retained\" >&2\nelse\n echo \"pihole-adlists: already matches the declared set - nothing to do\"\nfi\nexit 0\n"
|
||||||
- name: oauth2-proxy
|
- name: oauth2-proxy
|
||||||
image: quay.io/oauth2-proxy/oauth2-proxy:v7.8.1
|
image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.6
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
args:
|
args:
|
||||||
- --http-address=0.0.0.0:4180
|
- --http-address=0.0.0.0:4180
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ spec:
|
|||||||
, \"[Preferences]\"\n\ndef matches(raw):\n \"\"\"True iff the stored digest is one OUR password produces (salt is read from it).\"\"\"\n v = raw.strip().strip('\"')\n if v.startswith(\"@ByteArray(\") and v.endswith(\")\"):\n v = v[len(\"@ByteArray(\"):-1]\n try:\n salt_b64, digest_b64 = v.split(\":\", 1)\n salt = base64.b64decode(salt_b64, validate=True)\n digest = base64.b64decode(digest_b64, validate=True)\n except Exception:\n return False\n if not salt or not digest:\n return False\n return hmac.compare_digest(\n hashlib.pbkdf2_hmac(\"sha512\", pw.encode(), salt, ITERATIONS, len(digest)), digest)\n\ntry:\n lines = open(conf, encoding=\"utf-8\", errors=\"replace\").read().splitlines()\nexcept FileNotFoundError:\n lines = []\n\ndef value_of(key):\n for i, line in enumerate(lines):\n if line.startswith(key + \"=\"):\n return i, line.split(\"=\", 1)[1]\n return -1, None\n\nui, uval = value_of(UKEY)\npi, pval = value_of(PKEY)\nif uval == user and pval is not None and matches(pval):\n raise SystemExit(0) # converged - leave the file (and its salt) alone\n\nsalt = os.urandom(SALT_LEN)\ndigest = hashlib.pbkdf2_hmac(\"sha512\", pw.encode(), salt, ITERATIONS, DIGEST_LEN)\nstored = base64.b64encode(salt).decode() + \":\" + base64.b64encode(digest).decode()\ndesired = [UKEY + \"=\" + user, PKEY + '=\"@ByteArray(' + stored + ')\"']\n\nout = [line for i, line in enumerate(lines) if i not in (ui, pi)]\nif SECTION in out:\n at = out.index(SECTION) + 1\nelse:\n if out and out[-1].strip():\n out.append(\"\")\n out.append(SECTION)\n at = len(out)\nout[at:at] = desired\n\ntry:\n os.makedirs(os.path.dirname(conf) or \".\", exist_ok=True)\n tmp = conf + \".webui-cred.new\"\n with open(tmp, \"w\", encoding=\"utf-8\") as fh:\n fh.write(\"\\n\".join(out) + \"\\n\")\n fh.flush()\n os.fsync(fh.fileno())\n # This hook runs as ROOT; the service runs as PUID:PGID. A root-owned 0600 conf would\n # cost the app the ability to save its own settings, so carry the ownership over.\n try:\n st = os.stat(conf)\n os.chown(tmp, st.st_uid, st.st_gid)\n os.chmod(tmp, stat.S_IMODE(st.st_mode))\n except FileNotFoundError:\n os.chown(tmp, int(os.environ.get(\"PUID\", \"1000\")),\n int(os.environ.get(\"PGID\", \"1000\")))\n os.chmod(tmp, 0o664)\n os.replace(tmp, conf) # atomic: the app never reads a half-written conf\nexcept OSError as exc:\n sys.stderr.write(\"[qbt-webui-cred] cannot write %s: %s\\n\" % (conf, exc))\n raise SystemExit(1)\nraise SystemExit(10)\nPY\n rc=$?\n\n if [ \"$rc\" = 0 ]; then\n log \"conf already holds the durable credential for '$QBT_WEBUI_USERNAME' (no write)\"\n elif [ \"$rc\" = 10 ]; then\n log \"asserted WebUI Username + Password_PBKDF2 into $CONF\"\n # postStart runs CONCURRENTLY with the s6 init chain, so qbittorrent-nox may ALREADY\n # have read the old, credential-less conf and be serving a random temporary password.\n # Restart the SUPERVISED SERVICE (not the pod) so it re-reads what was just written.\n # Nothing to do when the service has not started yet - it will read the new file.\n # Its own counter, NOT the shared `waited` API budget: sharing one counter is how the\n # 2026-09-13 failure spent the entire verification budget before the first login and\n # then fired attempts 2 and 3 with no wait left at all.\n sv=0\n while [ \"$sv\" -lt 60 ] && [ \"$(s6-svstat -u \"$SVC\" 2>/dev/null)\" != \"true\" ]; do\n sv=$((sv + 1)); sleep 1\n done\n if [ \"$(s6-svstat -u \"$SVC\" 2>/dev/null)\" = \"true\" ]; then\n log \"qbittorrent-nox was already running - restarting it to load the credential\"\n s6-svc -r \"$SVC\" || log \"s6-svc -r failed (continuing to verify anyway)\"\n sleep 3\n fi\n else\n log \"FAILED to write the credential into $CONF (helper exit $rc). The WebUI will keep\" >&2\n log \"minting a random password\
|
, \"[Preferences]\"\n\ndef matches(raw):\n \"\"\"True iff the stored digest is one OUR password produces (salt is read from it).\"\"\"\n v = raw.strip().strip('\"')\n if v.startswith(\"@ByteArray(\") and v.endswith(\")\"):\n v = v[len(\"@ByteArray(\"):-1]\n try:\n salt_b64, digest_b64 = v.split(\":\", 1)\n salt = base64.b64decode(salt_b64, validate=True)\n digest = base64.b64decode(digest_b64, validate=True)\n except Exception:\n return False\n if not salt or not digest:\n return False\n return hmac.compare_digest(\n hashlib.pbkdf2_hmac(\"sha512\", pw.encode(), salt, ITERATIONS, len(digest)), digest)\n\ntry:\n lines = open(conf, encoding=\"utf-8\", errors=\"replace\").read().splitlines()\nexcept FileNotFoundError:\n lines = []\n\ndef value_of(key):\n for i, line in enumerate(lines):\n if line.startswith(key + \"=\"):\n return i, line.split(\"=\", 1)[1]\n return -1, None\n\nui, uval = value_of(UKEY)\npi, pval = value_of(PKEY)\nif uval == user and pval is not None and matches(pval):\n raise SystemExit(0) # converged - leave the file (and its salt) alone\n\nsalt = os.urandom(SALT_LEN)\ndigest = hashlib.pbkdf2_hmac(\"sha512\", pw.encode(), salt, ITERATIONS, DIGEST_LEN)\nstored = base64.b64encode(salt).decode() + \":\" + base64.b64encode(digest).decode()\ndesired = [UKEY + \"=\" + user, PKEY + '=\"@ByteArray(' + stored + ')\"']\n\nout = [line for i, line in enumerate(lines) if i not in (ui, pi)]\nif SECTION in out:\n at = out.index(SECTION) + 1\nelse:\n if out and out[-1].strip():\n out.append(\"\")\n out.append(SECTION)\n at = len(out)\nout[at:at] = desired\n\ntry:\n os.makedirs(os.path.dirname(conf) or \".\", exist_ok=True)\n tmp = conf + \".webui-cred.new\"\n with open(tmp, \"w\", encoding=\"utf-8\") as fh:\n fh.write(\"\\n\".join(out) + \"\\n\")\n fh.flush()\n os.fsync(fh.fileno())\n # This hook runs as ROOT; the service runs as PUID:PGID. A root-owned 0600 conf would\n # cost the app the ability to save its own settings, so carry the ownership over.\n try:\n st = os.stat(conf)\n os.chown(tmp, st.st_uid, st.st_gid)\n os.chmod(tmp, stat.S_IMODE(st.st_mode))\n except FileNotFoundError:\n os.chown(tmp, int(os.environ.get(\"PUID\", \"1000\")),\n int(os.environ.get(\"PGID\", \"1000\")))\n os.chmod(tmp, 0o664)\n os.replace(tmp, conf) # atomic: the app never reads a half-written conf\nexcept OSError as exc:\n sys.stderr.write(\"[qbt-webui-cred] cannot write %s: %s\\n\" % (conf, exc))\n raise SystemExit(1)\nraise SystemExit(10)\nPY\n rc=$?\n\n if [ \"$rc\" = 0 ]; then\n log \"conf already holds the durable credential for '$QBT_WEBUI_USERNAME' (no write)\"\n elif [ \"$rc\" = 10 ]; then\n log \"asserted WebUI Username + Password_PBKDF2 into $CONF\"\n # postStart runs CONCURRENTLY with the s6 init chain, so qbittorrent-nox may ALREADY\n # have read the old, credential-less conf and be serving a random temporary password.\n # Restart the SUPERVISED SERVICE (not the pod) so it re-reads what was just written.\n # Nothing to do when the service has not started yet - it will read the new file.\n # Its own counter, NOT the shared `waited` API budget: sharing one counter is how the\n # 2026-09-13 failure spent the entire verification budget before the first login and\n # then fired attempts 2 and 3 with no wait left at all.\n sv=0\n while [ \"$sv\" -lt 60 ] && [ \"$(s6-svstat -u \"$SVC\" 2>/dev/null)\" != \"true\" ]; do\n sv=$((sv + 1)); sleep 1\n done\n if [ \"$(s6-svstat -u \"$SVC\" 2>/dev/null)\" = \"true\" ]; then\n log \"qbittorrent-nox was already running - restarting it to load the credential\"\n s6-svc -r \"$SVC\" || log \"s6-svc -r failed (continuing to verify anyway)\"\n sleep 3\n fi\n else\n log \"FAILED to write the credential into $CONF (helper exit $rc). The WebUI will keep\" >&2\n log \"minting a random password\
|
||||||
\ every start and BOTH arrs will break. Fix and restart.\" >&2\n exit 0\n fi\n\n # (3) GROUND TRUTH: the WebUI itself must accept it. Wait for the API to answer at ALL\n # (shared ~120s budget across attempts), then spend exactly ONE login per attempt -\n # at most 3 rejected logins, which stays under qBittorrent's default ban threshold.\n #\n # \"THE API NEVER ANSWERED\" IS A DIFFERENT FAULT FROM \"THE PASSWORD WAS REJECTED\", and\n # conflating them is what made this hook lie once already (see the PORT comment). An\n # unreachable WebUI is reported as unreachable, and the hook stops rather than spending\n # its remaining login attempts to produce two more identical wrong diagnoses.\n api_ok=0\n while [ \"$waited\" -lt 120 ]; do\n code=$(curl -s -o /dev/null -m 3 -w '%{http_code}' \"http://127.0.0.1:$PORT/\" 2>/dev/null)\n if [ -n \"$code\" ] && [ \"$code\" != \"000\" ]; then api_ok=1; break; fi\n waited=$((waited + 1)); sleep 1\n done\n if [ \"$api_ok\" != 1 ]; then\n log \"the WebUI never answered on 127.0.0.1:$PORT within the wait budget, so the\" >&2\n log \"credential in $CONF is UNVERIFIED. This is NOT a rejected password - it was\" >&2\n log \"never submitted. Do not rotate the credential on the strength of this line.\" >&2\n exit 0\n fi\n body=$(curl -sS -m 10 \\\n --data-urlencode \"username=$QBT_WEBUI_USERNAME\" \\\n --data-urlencode \"password=$QBT_WEBUI_PASSWORD\" \\\n \"http://127.0.0.1:$PORT/api/v2/auth/login\" 2>/dev/null)\n if [ \"$body\" = \"Ok.\" ]; then\n log \"VERIFIED: the WebUI accepts the durable credential for '$QBT_WEBUI_USERNAME'\"\n exit 0\n fi\n log \"WebUI rejected the durable credential (login said '$body')\" >&2\n # A rejection when the conf was ALREADY converged (rc=0, nothing written) can only mean\n # the running qbittorrent-nox is serving something it read from an EARLIER conf, so\n # rewriting the same bytes on the next attempt would change nothing. The remedy is to\n # make the process RE-READ the file, so restart the supervised service and let the next\n # attempt re-verify. Without this the retry loop could never change its own outcome.\n if [ \"$(s6-svstat -u \"$SVC\" 2>/dev/null)\" = \"true\" ]; then\n log \"restarting qbittorrent-nox so it re-reads $CONF, then re-verifying\" >&2\n s6-svc -r \"$SVC\" || log \"s6-svc -r failed (continuing to verify anyway)\" >&2\n sleep 3\n fi\ndone\n\nlog \"GAVE UP after $attempt attempts: the WebUI does not accept the credential from git.\" >&2\n# A postStart hook MUST exit 0 - a non-zero exit makes the kubelet KILL the container\n# (FailedPostStartHook -> CrashLoopBackOff), turning a credential fault into an outage.\n# Both failure paths above therefore log LOUDLY and exit 0; the `up` SLI still watches the\n# app, and the arrs' own download-client health check surfaces a broken credential.\nexit 0\n"
|
\ every start and BOTH arrs will break. Fix and restart.\" >&2\n exit 0\n fi\n\n # (3) GROUND TRUTH: the WebUI itself must accept it. Wait for the API to answer at ALL\n # (shared ~120s budget across attempts), then spend exactly ONE login per attempt -\n # at most 3 rejected logins, which stays under qBittorrent's default ban threshold.\n #\n # \"THE API NEVER ANSWERED\" IS A DIFFERENT FAULT FROM \"THE PASSWORD WAS REJECTED\", and\n # conflating them is what made this hook lie once already (see the PORT comment). An\n # unreachable WebUI is reported as unreachable, and the hook stops rather than spending\n # its remaining login attempts to produce two more identical wrong diagnoses.\n api_ok=0\n while [ \"$waited\" -lt 120 ]; do\n code=$(curl -s -o /dev/null -m 3 -w '%{http_code}' \"http://127.0.0.1:$PORT/\" 2>/dev/null)\n if [ -n \"$code\" ] && [ \"$code\" != \"000\" ]; then api_ok=1; break; fi\n waited=$((waited + 1)); sleep 1\n done\n if [ \"$api_ok\" != 1 ]; then\n log \"the WebUI never answered on 127.0.0.1:$PORT within the wait budget, so the\" >&2\n log \"credential in $CONF is UNVERIFIED. This is NOT a rejected password - it was\" >&2\n log \"never submitted. Do not rotate the credential on the strength of this line.\" >&2\n exit 0\n fi\n body=$(curl -sS -m 10 \\\n --data-urlencode \"username=$QBT_WEBUI_USERNAME\" \\\n --data-urlencode \"password=$QBT_WEBUI_PASSWORD\" \\\n \"http://127.0.0.1:$PORT/api/v2/auth/login\" 2>/dev/null)\n if [ \"$body\" = \"Ok.\" ]; then\n log \"VERIFIED: the WebUI accepts the durable credential for '$QBT_WEBUI_USERNAME'\"\n exit 0\n fi\n log \"WebUI rejected the durable credential (login said '$body')\" >&2\n # A rejection when the conf was ALREADY converged (rc=0, nothing written) can only mean\n # the running qbittorrent-nox is serving something it read from an EARLIER conf, so\n # rewriting the same bytes on the next attempt would change nothing. The remedy is to\n # make the process RE-READ the file, so restart the supervised service and let the next\n # attempt re-verify. Without this the retry loop could never change its own outcome.\n if [ \"$(s6-svstat -u \"$SVC\" 2>/dev/null)\" = \"true\" ]; then\n log \"restarting qbittorrent-nox so it re-reads $CONF, then re-verifying\" >&2\n s6-svc -r \"$SVC\" || log \"s6-svc -r failed (continuing to verify anyway)\" >&2\n sleep 3\n fi\ndone\n\nlog \"GAVE UP after $attempt attempts: the WebUI does not accept the credential from git.\" >&2\n# A postStart hook MUST exit 0 - a non-zero exit makes the kubelet KILL the container\n# (FailedPostStartHook -> CrashLoopBackOff), turning a credential fault into an outage.\n# Both failure paths above therefore log LOUDLY and exit 0; the `up` SLI still watches the\n# app, and the arrs' own download-client health check surfaces a broken credential.\nexit 0\n"
|
||||||
- name: oauth2-proxy
|
- name: oauth2-proxy
|
||||||
image: quay.io/oauth2-proxy/oauth2-proxy:v7.8.1
|
image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.6
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
args:
|
args:
|
||||||
- --http-address=0.0.0.0:4180
|
- --http-address=0.0.0.0:4180
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ spec:
|
|||||||
- name: media
|
- name: media
|
||||||
mountPath: /media
|
mountPath: /media
|
||||||
- name: oauth2-proxy
|
- name: oauth2-proxy
|
||||||
image: quay.io/oauth2-proxy/oauth2-proxy:v7.8.1
|
image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.6
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
args:
|
args:
|
||||||
- --http-address=0.0.0.0:4180
|
- --http-address=0.0.0.0:4180
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ spec:
|
|||||||
- name: media
|
- name: media
|
||||||
mountPath: /media
|
mountPath: /media
|
||||||
- name: oauth2-proxy
|
- name: oauth2-proxy
|
||||||
image: quay.io/oauth2-proxy/oauth2-proxy:v7.8.1
|
image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.6
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
args:
|
args:
|
||||||
- --http-address=0.0.0.0:4180
|
- --http-address=0.0.0.0:4180
|
||||||
|
|||||||
+1
-1
@@ -129,7 +129,7 @@ AUTH_MODES = ("oidc", "forward-auth", "native", "none")
|
|||||||
# the sidecar authenticates the browser against Keycloak (homelab realm, the app's auth.groups)
|
# the sidecar authenticates the browser against Keycloak (homelab realm, the app's auth.groups)
|
||||||
# and only THEN proxies to the co-located app UI at http://127.0.0.1:<uiport>. The gate is IN
|
# and only THEN proxies to the co-located app UI at http://127.0.0.1:<uiport>. The gate is IN
|
||||||
# the data path (fail-closed: no valid session in an authorized group => no upstream; R-IDP-02).
|
# the data path (fail-closed: no valid session in an authorized group => no upstream; R-IDP-02).
|
||||||
OAUTH2_PROXY_IMAGE = "quay.io/oauth2-proxy/oauth2-proxy:v7.8.1"
|
OAUTH2_PROXY_IMAGE = "quay.io/oauth2-proxy/oauth2-proxy:v7.15.6"
|
||||||
OAUTH2_PROXY_PORT = 4180
|
OAUTH2_PROXY_PORT = 4180
|
||||||
KEYCLOAK_ISSUER = f"https://keycloak.{ZONE}/realms/homelab"
|
KEYCLOAK_ISSUER = f"https://keycloak.{ZONE}/realms/homelab"
|
||||||
|
|
||||||
|
|||||||
+2
-38
@@ -25,44 +25,8 @@ if [ "${#files[@]}" -eq 0 ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# yamllint is single-threaded, so the file list is split into LINT_JOBS CONTIGUOUS slices of the
|
yamllint -c "${CI_DIR}/yamllint.yaml" -f "${YAMLLINT_FORMAT:-standard}" "${files[@]}"
|
||||||
# sorted list, one yamllint per slice, run concurrently. Each slice's output goes to its own file
|
ok "yaml/lint passed (${#files[@]} files)"
|
||||||
# and the files are printed in slice order afterwards: the log is the same text, in the same
|
|
||||||
# file order, as one serial `yamllint "${files[@]}"`, and a finding is never interleaved with
|
|
||||||
# another process's output (yamllint's standard format puts the FILE NAME on its own line above
|
|
||||||
# that file's `line:col` findings, so interleaving would misattribute them). The stage's exit
|
|
||||||
# code is the highest any slice returned (yamllint: 1 = error, 2 = warning under strict), so a
|
|
||||||
# single failing slice fails the stage exactly as the serial call did. LINT_JOBS=1 is serial.
|
|
||||||
lint_jobs="${LINT_JOBS:-$(n="$(nproc 2>/dev/null || echo 1)"; [ "$n" -gt 8 ] && n=8; echo "$n")}"
|
|
||||||
case "$lint_jobs" in ''|*[!0-9]*|0) err "LINT_JOBS must be a positive integer, got '${lint_jobs}'"; exit 2 ;; esac
|
|
||||||
[ "$lint_jobs" -gt "${#files[@]}" ] && lint_jobs="${#files[@]}"
|
|
||||||
lint_tmp="$(mktemp -d)"
|
|
||||||
trap 'rm -rf "$lint_tmp"' EXIT
|
|
||||||
per=$(( (${#files[@]} + lint_jobs - 1) / lint_jobs ))
|
|
||||||
pids=()
|
|
||||||
for (( i = 0; i < lint_jobs; i++ )); do
|
|
||||||
slice=("${files[@]:$(( i * per )):${per}}")
|
|
||||||
[ "${#slice[@]}" -gt 0 ] || break
|
|
||||||
( rc=0
|
|
||||||
yamllint -c "${CI_DIR}/yamllint.yaml" -f "${YAMLLINT_FORMAT:-standard}" "${slice[@]}" \
|
|
||||||
>"${lint_tmp}/${i}.out" 2>&1 || rc=$?
|
|
||||||
echo "$rc" >"${lint_tmp}/${i}.rc" ) &
|
|
||||||
pids+=("$!")
|
|
||||||
done
|
|
||||||
wait "${pids[@]}" || true
|
|
||||||
lint_rc=0
|
|
||||||
for (( i = 0; i < ${#pids[@]}; i++ )); do
|
|
||||||
cat "${lint_tmp}/${i}.out"
|
|
||||||
# A slice that left no rc file died before yamllint returned: that is a failure, not a pass.
|
|
||||||
slice_rc="$(cat "${lint_tmp}/${i}.rc" 2>/dev/null || true)"
|
|
||||||
case "$slice_rc" in ''|*[!0-9]*) slice_rc=1 ;; esac
|
|
||||||
[ "$slice_rc" -gt "$lint_rc" ] && lint_rc="$slice_rc"
|
|
||||||
done
|
|
||||||
if [ "$lint_rc" -ne 0 ]; then
|
|
||||||
err "yaml/lint failed (yamllint exit ${lint_rc}; ${#files[@]} files in ${#pids[@]} parallel slice(s))"
|
|
||||||
exit "$lint_rc"
|
|
||||||
fi
|
|
||||||
ok "yaml/lint passed (${#files[@]} files, ${#pids[@]} parallel slice(s))"
|
|
||||||
|
|
||||||
# A workflow's `configurationFile:` must name a file that exists. Renovate's did not after #79
|
# A workflow's `configurationFile:` must name a file that exists. Renovate's did not after #79
|
||||||
# renamed renovate.json -> renovate.json5, and every scheduled run failed with ENOENT for 11 days
|
# renamed renovate.json -> renovate.json5, and every scheduled run failed with ENOENT for 11 days
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Pure standard library (matches the harness's no-pip-dependency posture); the che
|
|||||||
themselves shell out (render checks to catalog/render.py, whose PyYAML dependency lives
|
themselves shell out (render checks to catalog/render.py, whose PyYAML dependency lives
|
||||||
there; L0 checks to git + PyYAML).
|
there; L0 checks to git + PyYAML).
|
||||||
|
|
||||||
Usage: run_l1_offline.py --junit <out.xml> [--label-prefix P] [--jobs N] <test-dir> [<test-dir> ...]
|
Usage: run_l1_offline.py --junit <out.xml> [--label-prefix P] <test-dir> [<test-dir> ...]
|
||||||
Exit 0 iff every selected offline check passes.
|
Exit 0 iff every selected offline check passes.
|
||||||
|
|
||||||
The per-dir check script is whichever of `render-check.sh` / `check.sh` the directory
|
The per-dir check script is whichever of `render-check.sh` / `check.sh` the directory
|
||||||
@@ -43,35 +43,13 @@ Exit codes: 0 all selected checks passed / 1 at least one FAILED / 3 nothing fai
|
|||||||
scope-declared check was UNWIRED. conformance/run.sh propagates 3 verbatim; ci/l0.sh turns it
|
scope-declared check was UNWIRED. conformance/run.sh propagates 3 verbatim; ci/l0.sh turns it
|
||||||
into the gate's own rc 3. This is the ONE place the "unwired, not green" rule lives now,
|
into the gate's own rc 3. This is the ONE place the "unwired, not green" rule lives now,
|
||||||
instead of once per hand-written stage.
|
instead of once per hand-written stage.
|
||||||
|
|
||||||
PARALLELISM (--jobs, env CONF_OFFLINE_JOBS)
|
|
||||||
-------------------------------------------
|
|
||||||
The checks are independent processes, so they run in a bounded thread pool (one `bash` child
|
|
||||||
per worker). Default: min(8, usable CPUs); `--jobs 1` is the old serial runner exactly. Each
|
|
||||||
check's stdout+stderr is CAPTURED and only reported after every check has finished, in the
|
|
||||||
ORDER THE DIRS WERE GIVEN, so the log, the JUnit and the step summary are byte-for-byte
|
|
||||||
independent of which check finished first (only the trailing "[N.Ns]" wall-time on each
|
|
||||||
status line varies). The 0/1/3 aggregation runs over the same ordered case list as before.
|
|
||||||
|
|
||||||
Parallel-safety is a property of the CHECKS, not of this runner: every check must keep its
|
|
||||||
scratch state in a private mktemp/mkdtemp dir and must not write into the tree or a fixed
|
|
||||||
path. The audit that established that for the current set is recorded in
|
|
||||||
decisions/2026-11-08-parallel-l0-checks.md; a new check that needs shared state must say so.
|
|
||||||
|
|
||||||
Nested pools: the keycloak reconciler checks run their own mutation suites in a pool
|
|
||||||
(mutations.py, env MUTATION_JOBS). When this runner is itself parallel it exports a SMALLER
|
|
||||||
MUTATION_JOBS (unless the caller already set one), so the two suites share the CPUs the
|
|
||||||
other jobs-2 checks are not using instead of each taking min(8, CPUs). See inner_jobs().
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from xml.dom import minidom
|
from xml.dom import minidom
|
||||||
@@ -119,46 +97,6 @@ def collect_meta(test_yaml: Path, prefix: str):
|
|||||||
return name, reqs
|
return name, reqs
|
||||||
|
|
||||||
|
|
||||||
def usable_cpus() -> int:
|
|
||||||
"""CPUs this process may run on (the cgroup/affinity view, not the host's count)."""
|
|
||||||
try:
|
|
||||||
return max(1, len(os.sched_getaffinity(0)))
|
|
||||||
except (AttributeError, OSError):
|
|
||||||
return max(1, os.cpu_count() or 1)
|
|
||||||
|
|
||||||
|
|
||||||
def positive_int(text: str) -> int:
|
|
||||||
n = int(text)
|
|
||||||
if n < 1:
|
|
||||||
raise argparse.ArgumentTypeError(f"must be >= 1, got {n}")
|
|
||||||
return n
|
|
||||||
|
|
||||||
|
|
||||||
def inner_jobs(cpus: int, outer: int) -> int:
|
|
||||||
"""MUTATION_JOBS to hand the checks when this runner runs `outer` of them at once.
|
|
||||||
|
|
||||||
Two checks (the keycloak client-secret and passkey reconcilers) fan out internally; the
|
|
||||||
other outer-2 workers are single processes. Give the two suites an equal share of the CPUs
|
|
||||||
those others leave free, never fewer than 2 each (a 1-wide pool would make the 40-mutation
|
|
||||||
passkey suite the critical path of the whole stage), never more than 8. Peak process count
|
|
||||||
is then about (outer-2) + 2*inner, i.e. <= cpus on any machine with cpus >= outer + 2, and
|
|
||||||
at worst ~1.25-1.5x cpus on a small runner — CPU-bound oversubscription of that size costs
|
|
||||||
latency per process, not throughput.
|
|
||||||
"""
|
|
||||||
if outer <= 1:
|
|
||||||
return min(8, cpus)
|
|
||||||
return max(2, min(8, (cpus - outer + 2) // 2))
|
|
||||||
|
|
||||||
|
|
||||||
def run_check(check: Path, env: dict) -> tuple[int, str, float]:
|
|
||||||
t0 = time.monotonic()
|
|
||||||
proc = subprocess.run(
|
|
||||||
["bash", str(check)], capture_output=True, text=True,
|
|
||||||
stdin=subprocess.DEVNULL, env=env,
|
|
||||||
)
|
|
||||||
return proc.returncode, (proc.stdout + proc.stderr).strip(), time.monotonic() - t0
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
ap = argparse.ArgumentParser(description=__doc__)
|
ap = argparse.ArgumentParser(description=__doc__)
|
||||||
ap.add_argument("--junit", type=Path, required=True)
|
ap.add_argument("--junit", type=Path, required=True)
|
||||||
@@ -170,54 +108,26 @@ def main() -> int:
|
|||||||
"(makes this runner exit 3); repeatable")
|
"(makes this runner exit 3); repeatable")
|
||||||
ap.add_argument("--summary-out", type=Path, default=None,
|
ap.add_argument("--summary-out", type=Path, default=None,
|
||||||
help="write one Markdown line per case here, for the CI step summary")
|
help="write one Markdown line per case here, for the CI step summary")
|
||||||
ap.add_argument("--jobs", type=positive_int, default=None,
|
|
||||||
help="checks to run at once (default: env CONF_OFFLINE_JOBS, else "
|
|
||||||
"min(8, usable CPUs)); 1 = serial")
|
|
||||||
ap.add_argument("dirs", nargs="+", type=Path)
|
ap.add_argument("dirs", nargs="+", type=Path)
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
strict_scopes = set(args.unwired_scope)
|
strict_scopes = set(args.unwired_scope)
|
||||||
|
|
||||||
cpus = usable_cpus()
|
cases = [] # (name, reqs, dir, rc, output, scope)
|
||||||
if args.jobs is not None:
|
|
||||||
jobs = args.jobs
|
|
||||||
elif os.environ.get("CONF_OFFLINE_JOBS", "").strip():
|
|
||||||
try:
|
|
||||||
jobs = positive_int(os.environ["CONF_OFFLINE_JOBS"].strip())
|
|
||||||
except (ValueError, argparse.ArgumentTypeError) as e:
|
|
||||||
ap.error(f"CONF_OFFLINE_JOBS={os.environ['CONF_OFFLINE_JOBS']!r}: {e}")
|
|
||||||
else:
|
|
||||||
jobs = min(8, cpus)
|
|
||||||
child_env = dict(os.environ)
|
|
||||||
if not child_env.get("MUTATION_JOBS", "").strip():
|
|
||||||
child_env["MUTATION_JOBS"] = str(inner_jobs(cpus, jobs))
|
|
||||||
print(f"[offline] running {len(args.dirs)} check(s), jobs={jobs} "
|
|
||||||
f"(cpus={cpus}, MUTATION_JOBS={child_env['MUTATION_JOBS']})", file=sys.stderr)
|
|
||||||
|
|
||||||
# Metadata first, in the given order; then the checks themselves through the pool. The
|
|
||||||
# results are consumed in SUBMISSION order, so `cases` is identical to the serial runner's.
|
|
||||||
planned = [] # (name, reqs, dir, scope, check-or-None)
|
|
||||||
for d in args.dirs:
|
for d in args.dirs:
|
||||||
check = next((d / s for s in CHECK_SCRIPTS if (d / s).exists()), None)
|
check = next((d / s for s in CHECK_SCRIPTS if (d / s).exists()), None)
|
||||||
name, reqs = collect_meta(d / "chainsaw-test.yaml", args.label_prefix)
|
name, reqs = collect_meta(d / "chainsaw-test.yaml", args.label_prefix)
|
||||||
scope = scope_of(d / "chainsaw-test.yaml", args.scope_annotation)
|
scope = scope_of(d / "chainsaw-test.yaml", args.scope_annotation)
|
||||||
planned.append((name, reqs, d, scope, check))
|
if check is None:
|
||||||
|
cases.append((name, reqs, d, 3,
|
||||||
cases = [] # (name, reqs, dir, rc, output, scope)
|
f"no offline check script ({' or '.join(CHECK_SCRIPTS)}) in {d}",
|
||||||
elapsed = [] # wall seconds per case, same order (None: nothing ran)
|
scope))
|
||||||
with ThreadPoolExecutor(max_workers=jobs) as pool:
|
continue
|
||||||
futures = [pool.submit(run_check, check, child_env) if check is not None else None
|
proc = subprocess.run(
|
||||||
for (_n, _r, _d, _s, check) in planned]
|
["bash", str(check)], capture_output=True, text=True
|
||||||
for (name, reqs, d, scope, check), fut in zip(planned, futures):
|
)
|
||||||
if fut is None:
|
out = (proc.stdout + proc.stderr).strip()
|
||||||
cases.append((name, reqs, d, 3,
|
cases.append((name, reqs, d, proc.returncode, out, scope))
|
||||||
f"no offline check script ({' or '.join(CHECK_SCRIPTS)}) in {d}",
|
|
||||||
scope))
|
|
||||||
elapsed.append(None)
|
|
||||||
continue
|
|
||||||
rc, out, secs = fut.result()
|
|
||||||
cases.append((name, reqs, d, rc, out, scope))
|
|
||||||
elapsed.append(secs)
|
|
||||||
|
|
||||||
failures = sum(1 for c in cases if c[3] not in (0, 3))
|
failures = sum(1 for c in cases if c[3] not in (0, 3))
|
||||||
skipped = sum(1 for c in cases if c[3] == 3)
|
skipped = sum(1 for c in cases if c[3] == 3)
|
||||||
@@ -235,7 +145,7 @@ def main() -> int:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
summary_lines = []
|
summary_lines = []
|
||||||
for (name, reqs, d, rc, out, scope), secs in zip(cases, elapsed):
|
for name, reqs, d, rc, out, scope in cases:
|
||||||
tc = ET.SubElement(
|
tc = ET.SubElement(
|
||||||
suite,
|
suite,
|
||||||
"testcase",
|
"testcase",
|
||||||
@@ -254,9 +164,8 @@ def main() -> int:
|
|||||||
fail.text = out
|
fail.text = out
|
||||||
status = "PASS" if rc == 0 else ("SKIP" if rc == 3 else "FAIL")
|
status = "PASS" if rc == 0 else ("SKIP" if rc == 3 else "FAIL")
|
||||||
mark = f" scope={scope}" if scope else ""
|
mark = f" scope={scope}" if scope else ""
|
||||||
took = f" [{secs:.1f}s]" if secs is not None else ""
|
|
||||||
print(f"[offline] {layer_of(d):3s} {status:4s} {name} "
|
print(f"[offline] {layer_of(d):3s} {status:4s} {name} "
|
||||||
f"({','.join(reqs) or 'untagged'}){mark}{took}",
|
f"({','.join(reqs) or 'untagged'}){mark}",
|
||||||
file=sys.stderr)
|
file=sys.stderr)
|
||||||
if rc not in (0, 3):
|
if rc not in (0, 3):
|
||||||
print(out, file=sys.stderr)
|
print(out, file=sys.stderr)
|
||||||
|
|||||||
@@ -25,19 +25,15 @@
|
|||||||
#
|
#
|
||||||
# python3 mutations.py <path-to-extracted-reconciler.py>
|
# python3 mutations.py <path-to-extracted-reconciler.py>
|
||||||
#
|
#
|
||||||
# Runtime is ~11s serial (one subprocess per mutation, each running the full selftest
|
# Runtime is ~20s (one subprocess per mutation, each running the full selftest
|
||||||
# against an in-process stub Keycloak; no network, no cluster). The selftests run in a
|
# against an in-process stub Keycloak; no network, no cluster).
|
||||||
# bounded pool (env MUTATION_JOBS, default min(8, CPUs); 1 = serial); output order and
|
|
||||||
# verdicts are independent of the pool size.
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
@@ -140,99 +136,43 @@ MUTATIONS: list[tuple[str, str, str, str]] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def mutation_jobs() -> int:
|
|
||||||
"""Selftests to run at once: env MUTATION_JOBS, else min(8, usable CPUs).
|
|
||||||
|
|
||||||
conformance/lib/run_l1_offline.py exports a smaller MUTATION_JOBS when it is itself
|
|
||||||
running several checks in parallel, so the nested pools do not oversubscribe the host.
|
|
||||||
MUTATION_JOBS=1 reproduces the old serial loop exactly.
|
|
||||||
"""
|
|
||||||
raw = os.environ.get("MUTATION_JOBS", "").strip()
|
|
||||||
if raw:
|
|
||||||
try:
|
|
||||||
n = int(raw)
|
|
||||||
except ValueError:
|
|
||||||
n = 0
|
|
||||||
if n < 1:
|
|
||||||
raise ValueError("MUTATION_JOBS must be a positive integer, got %r" % raw)
|
|
||||||
return n
|
|
||||||
try:
|
|
||||||
cpus = len(os.sched_getaffinity(0))
|
|
||||||
except (AttributeError, OSError):
|
|
||||||
cpus = os.cpu_count() or 1
|
|
||||||
return max(1, min(8, cpus))
|
|
||||||
|
|
||||||
|
|
||||||
def run_selftest(mutated_src: str) -> subprocess.CompletedProcess:
|
|
||||||
with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as fh:
|
|
||||||
fh.write(mutated_src)
|
|
||||||
path = fh.name
|
|
||||||
try:
|
|
||||||
return subprocess.run([sys.executable, path, "--selftest"],
|
|
||||||
capture_output=True, text=True, timeout=240,
|
|
||||||
stdin=subprocess.DEVNULL)
|
|
||||||
finally:
|
|
||||||
Path(path).unlink(missing_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
if len(sys.argv) != 2:
|
if len(sys.argv) != 2:
|
||||||
print("usage: mutations.py <extracted-reconciler.py>", file=sys.stderr)
|
print("usage: mutations.py <extracted-reconciler.py>", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
src = Path(sys.argv[1]).read_text()
|
src = Path(sys.argv[1]).read_text()
|
||||||
|
|
||||||
try:
|
problems: list[str] = []
|
||||||
jobs = mutation_jobs()
|
killed = 0
|
||||||
except ValueError as e:
|
|
||||||
print("mutations.py: %s" % e, file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
|
|
||||||
# Pass 1, serial and cheap: every anchor is checked BEFORE anything runs, exactly as the
|
|
||||||
# serial loop did, and each mutation's source is built here. None = anchor failure.
|
|
||||||
planned: list[tuple[str, str, str | None, str | None]] = []
|
|
||||||
for name, why, old, new in MUTATIONS:
|
for name, why, old, new in MUTATIONS:
|
||||||
old, new = dedent4(old), dedent4(new)
|
old, new = dedent4(old), dedent4(new)
|
||||||
# An anchor that no longer matches is a FAILURE, not a skip: it means the code
|
# An anchor that no longer matches is a FAILURE, not a skip: it means the code
|
||||||
# moved and this mutation silently stopped proving anything.
|
# moved and this mutation silently stopped proving anything.
|
||||||
n = src.count(old)
|
n = src.count(old)
|
||||||
if n != 1:
|
if n != 1:
|
||||||
planned.append((name, why,
|
problems.append("%s: anchor matches %d times (expected exactly 1) -- the "
|
||||||
"%s: anchor matches %d times (expected exactly 1) -- the "
|
|
||||||
"code moved and this mutation no longer proves anything. "
|
"code moved and this mutation no longer proves anything. "
|
||||||
"Re-anchor it against the current source." % (name, n), None))
|
"Re-anchor it against the current source." % (name, n))
|
||||||
continue
|
continue
|
||||||
planned.append((name, why, None, src.replace(old, new, 1)))
|
with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as fh:
|
||||||
|
fh.write(src.replace(old, new, 1))
|
||||||
# Pass 2: the selftests, `jobs` at a time. Each child gets its own temp file, its own
|
path = fh.name
|
||||||
# mkdtemp dirs and its own 127.0.0.1:0 stub port, so they share nothing. Results are
|
try:
|
||||||
# consumed in MUTATIONS order, so every line below is printed in the same order, with the
|
proc = subprocess.run([sys.executable, path, "--selftest"],
|
||||||
# same verdict, as the serial loop produced -- whichever child finishes first.
|
capture_output=True, text=True, timeout=240)
|
||||||
problems: list[str] = []
|
finally:
|
||||||
killed = 0
|
Path(path).unlink(missing_ok=True)
|
||||||
pool = ThreadPoolExecutor(max_workers=jobs)
|
if proc.returncode == 0:
|
||||||
try:
|
problems.append("%s SURVIVED -- --selftest still passed with this regression "
|
||||||
futures = [pool.submit(run_selftest, mutated) if mutated is not None else None
|
"applied, so nothing in the suite actually checks it. The "
|
||||||
for (_n, _w, _a, mutated) in planned]
|
"mistake it models: %s" % (name, why))
|
||||||
for (name, why, anchor_problem, _m), fut in zip(planned, futures):
|
continue
|
||||||
if anchor_problem is not None:
|
killed += 1
|
||||||
problems.append(anchor_problem)
|
out = proc.stdout + proc.stderr
|
||||||
continue
|
m = re.search(r"SELFTEST FAIL: (\d+) of \d+", out)
|
||||||
proc = fut.result()
|
how = ("%s assertion(s) caught it" % m.group(1) if m
|
||||||
if proc.returncode == 0:
|
else "the selftest aborted (caught, less cleanly)")
|
||||||
problems.append("%s SURVIVED -- --selftest still passed with this regression "
|
print(" killed %-42s %s" % (name, how))
|
||||||
"applied, so nothing in the suite actually checks it. The "
|
|
||||||
"mistake it models: %s" % (name, why))
|
|
||||||
continue
|
|
||||||
killed += 1
|
|
||||||
out = proc.stdout + proc.stderr
|
|
||||||
m = re.search(r"SELFTEST FAIL: (\d+) of \d+", out)
|
|
||||||
how = ("%s assertion(s) caught it" % m.group(1) if m
|
|
||||||
else "the selftest aborted (caught, less cleanly)")
|
|
||||||
print(" killed %-42s %s" % (name, how))
|
|
||||||
finally:
|
|
||||||
# A timeout (or any other exception) still propagates and fails the check, as it did
|
|
||||||
# serially; queued mutations are cancelled rather than run for nothing.
|
|
||||||
pool.shutdown(wait=True, cancel_futures=True)
|
|
||||||
|
|
||||||
print()
|
print()
|
||||||
if problems:
|
if problems:
|
||||||
|
|||||||
@@ -40,19 +40,15 @@
|
|||||||
#
|
#
|
||||||
# python3 mutations.py <path-to-extracted-reconciler.py>
|
# python3 mutations.py <path-to-extracted-reconciler.py>
|
||||||
#
|
#
|
||||||
# Runtime is ~85s serial (one subprocess per mutation, each running the full selftest
|
# Runtime is ~60s (one subprocess per mutation, each running the full selftest
|
||||||
# against an in-process stub Keycloak; no network, no cluster). The selftests run in a
|
# against an in-process stub Keycloak; no network, no cluster).
|
||||||
# bounded pool (env MUTATION_JOBS, default min(8, CPUs); 1 = serial); output order and
|
|
||||||
# verdicts are independent of the pool size.
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# (name, the mistake being modelled, anchor, replacement)
|
# (name, the mistake being modelled, anchor, replacement)
|
||||||
@@ -407,98 +403,42 @@ MUTATIONS: list[tuple[str, str, str, str]] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def mutation_jobs() -> int:
|
|
||||||
"""Selftests to run at once: env MUTATION_JOBS, else min(8, usable CPUs).
|
|
||||||
|
|
||||||
conformance/lib/run_l1_offline.py exports a smaller MUTATION_JOBS when it is itself
|
|
||||||
running several checks in parallel, so the nested pools do not oversubscribe the host.
|
|
||||||
MUTATION_JOBS=1 reproduces the old serial loop exactly.
|
|
||||||
"""
|
|
||||||
raw = os.environ.get("MUTATION_JOBS", "").strip()
|
|
||||||
if raw:
|
|
||||||
try:
|
|
||||||
n = int(raw)
|
|
||||||
except ValueError:
|
|
||||||
n = 0
|
|
||||||
if n < 1:
|
|
||||||
raise ValueError("MUTATION_JOBS must be a positive integer, got %r" % raw)
|
|
||||||
return n
|
|
||||||
try:
|
|
||||||
cpus = len(os.sched_getaffinity(0))
|
|
||||||
except (AttributeError, OSError):
|
|
||||||
cpus = os.cpu_count() or 1
|
|
||||||
return max(1, min(8, cpus))
|
|
||||||
|
|
||||||
|
|
||||||
def run_selftest(mutated_src: str) -> subprocess.CompletedProcess:
|
|
||||||
with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as fh:
|
|
||||||
fh.write(mutated_src)
|
|
||||||
path = fh.name
|
|
||||||
try:
|
|
||||||
return subprocess.run([sys.executable, path, "--selftest"],
|
|
||||||
capture_output=True, text=True, timeout=300,
|
|
||||||
stdin=subprocess.DEVNULL)
|
|
||||||
finally:
|
|
||||||
Path(path).unlink(missing_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
if len(sys.argv) != 2:
|
if len(sys.argv) != 2:
|
||||||
print("usage: mutations.py <extracted-reconciler.py>", file=sys.stderr)
|
print("usage: mutations.py <extracted-reconciler.py>", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
src = Path(sys.argv[1]).read_text()
|
src = Path(sys.argv[1]).read_text()
|
||||||
|
|
||||||
try:
|
problems: list[str] = []
|
||||||
jobs = mutation_jobs()
|
killed = 0
|
||||||
except ValueError as e:
|
|
||||||
print("mutations.py: %s" % e, file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
|
|
||||||
# Pass 1, serial and cheap: every anchor is checked BEFORE anything runs, exactly as the
|
|
||||||
# serial loop did, and each mutation's source is built here. None = anchor failure.
|
|
||||||
planned: list[tuple[str, str, str | None, str | None]] = []
|
|
||||||
for name, why, old, new in MUTATIONS:
|
for name, why, old, new in MUTATIONS:
|
||||||
# An anchor that no longer matches is a FAILURE, not a skip: it means the code
|
# An anchor that no longer matches is a FAILURE, not a skip: it means the code
|
||||||
# moved and this mutation silently stopped proving anything.
|
# moved and this mutation silently stopped proving anything.
|
||||||
n = src.count(old)
|
n = src.count(old)
|
||||||
if n != 1:
|
if n != 1:
|
||||||
planned.append((name, why,
|
problems.append("%s: anchor matches %d times (expected exactly 1) -- the "
|
||||||
"%s: anchor matches %d times (expected exactly 1) -- the "
|
|
||||||
"code moved and this mutation no longer proves anything. "
|
"code moved and this mutation no longer proves anything. "
|
||||||
"Re-anchor it against the current source." % (name, n), None))
|
"Re-anchor it against the current source." % (name, n))
|
||||||
continue
|
continue
|
||||||
planned.append((name, why, None, src.replace(old, new, 1)))
|
with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as fh:
|
||||||
|
fh.write(src.replace(old, new, 1))
|
||||||
# Pass 2: the selftests, `jobs` at a time. Each child gets its own temp file, its own
|
path = fh.name
|
||||||
# mkdtemp dirs and its own 127.0.0.1:0 stub port, so they share nothing. Results are
|
try:
|
||||||
# consumed in MUTATIONS order, so every line below is printed in the same order, with the
|
proc = subprocess.run([sys.executable, path, "--selftest"],
|
||||||
# same verdict, as the serial loop produced -- whichever child finishes first.
|
capture_output=True, text=True, timeout=300)
|
||||||
problems: list[str] = []
|
finally:
|
||||||
killed = 0
|
Path(path).unlink(missing_ok=True)
|
||||||
pool = ThreadPoolExecutor(max_workers=jobs)
|
if proc.returncode == 0:
|
||||||
try:
|
problems.append("%s SURVIVED -- --selftest still passed with this regression "
|
||||||
futures = [pool.submit(run_selftest, mutated) if mutated is not None else None
|
"applied, so nothing in the suite actually checks it. The "
|
||||||
for (_n, _w, _a, mutated) in planned]
|
"mistake it models: %s" % (name, why))
|
||||||
for (name, why, anchor_problem, _m), fut in zip(planned, futures):
|
continue
|
||||||
if anchor_problem is not None:
|
killed += 1
|
||||||
problems.append(anchor_problem)
|
out = proc.stdout + proc.stderr
|
||||||
continue
|
m = re.search(r"SELFTEST FAIL: (\d+) of \d+", out)
|
||||||
proc = fut.result()
|
how = ("%s assertion(s) caught it" % m.group(1) if m
|
||||||
if proc.returncode == 0:
|
else "the selftest aborted (caught, less cleanly)")
|
||||||
problems.append("%s SURVIVED -- --selftest still passed with this regression "
|
print(" killed %-54s %s" % (name, how))
|
||||||
"applied, so nothing in the suite actually checks it. The "
|
|
||||||
"mistake it models: %s" % (name, why))
|
|
||||||
continue
|
|
||||||
killed += 1
|
|
||||||
out = proc.stdout + proc.stderr
|
|
||||||
m = re.search(r"SELFTEST FAIL: (\d+) of \d+", out)
|
|
||||||
how = ("%s assertion(s) caught it" % m.group(1) if m
|
|
||||||
else "the selftest aborted (caught, less cleanly)")
|
|
||||||
print(" killed %-54s %s" % (name, how))
|
|
||||||
finally:
|
|
||||||
# A timeout (or any other exception) still propagates and fails the check, as it did
|
|
||||||
# serially; queued mutations are cancelled rather than run for nothing.
|
|
||||||
pool.shutdown(wait=True, cancel_futures=True)
|
|
||||||
|
|
||||||
print()
|
print()
|
||||||
if problems:
|
if problems:
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ spec:
|
|||||||
ports:
|
ports:
|
||||||
- containerPort: 8080
|
- containerPort: 8080
|
||||||
- name: oauth2-proxy
|
- name: oauth2-proxy
|
||||||
image: quay.io/oauth2-proxy/oauth2-proxy:v7.8.1
|
image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.6
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
args:
|
args:
|
||||||
- --http-address=0.0.0.0:4180
|
- --http-address=0.0.0.0:4180
|
||||||
|
|||||||
+1
-1
@@ -85,7 +85,7 @@ spec:
|
|||||||
- name: config
|
- name: config
|
||||||
mountPath: /data/config
|
mountPath: /data/config
|
||||||
- name: oauth2-proxy
|
- name: oauth2-proxy
|
||||||
image: quay.io/oauth2-proxy/oauth2-proxy:v7.8.1
|
image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.6
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
args:
|
args:
|
||||||
- --http-address=0.0.0.0:4180
|
- --http-address=0.0.0.0:4180
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ spec:
|
|||||||
- name: config
|
- name: config
|
||||||
mountPath: /data/config
|
mountPath: /data/config
|
||||||
- name: oauth2-proxy
|
- name: oauth2-proxy
|
||||||
image: quay.io/oauth2-proxy/oauth2-proxy:v7.8.1
|
image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.6
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
args:
|
args:
|
||||||
- --http-address=0.0.0.0:4180
|
- --http-address=0.0.0.0:4180
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
---
|
||||||
|
date: 2026-11-08
|
||||||
|
branch: fix/keycloak-servicemonitor-owner
|
||||||
|
title: One owner for the keycloak ServiceMonitor — disable the 26.8 operator's (409 loop stalled platform-config)
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
- `platform/keycloak/keycloak-instance.yaml`: added `spec.serviceMonitor.enabled: false`.
|
||||||
|
The 26.x Keycloak operator creates its own ServiceMonitor, named after the CR (`keycloak/keycloak`),
|
||||||
|
whenever `metrics-enabled=true` and the ServiceMonitor CRD exists. `serviceMonitor.enabled` defaults
|
||||||
|
to `true` in the 26.8.2 CRD, both in v2alpha1 and in v2beta1 (v2beta1 is now the storage version).
|
||||||
|
That is the same name and namespace as the repo's Flux-owned `platform/keycloak/servicemonitor.yaml`.
|
||||||
|
After the 26.0.6 -> 26.8.2 bump (#121/#144) the operator's PATCH against it returned
|
||||||
|
`409 Conflict` and it ran out of retries. The CR then sat at `Ready=False`/`HasErrors=True`, and
|
||||||
|
`platform-config` (`wait: true`, which health-checks every object it applies, this CR included)
|
||||||
|
blocked everything that `dependsOn` it: velero, apps-vms, apps-wave-0 (and so waves 1/2 and
|
||||||
|
pm-agent), homepage, oauth2-proxy, headlamp, conformance, vpn-ingress, crafty, runbooks.
|
||||||
|
- `platform/keycloak/servicemonitor.yaml`: a comment saying it is the sole owner.
|
||||||
|
- **Why ours is kept:** these are written against it: the L0 `servicemonitor-ports-resolve`
|
||||||
|
allow-list entry `platform/keycloak/servicemonitor.yaml:keycloak`, the L2 `keycloak-monitored`
|
||||||
|
chainsaw test, the README's ownership table, and the dashboard's `up{namespace="keycloak"}`.
|
||||||
|
The operator's version (upstream `KeycloakServiceMonitorDependentResource` @26.8.2) scrapes the same
|
||||||
|
target: port name `management` (9000 on `keycloak-service`), path `/metrics`, interval 30s,
|
||||||
|
selector = the operator's instance labels. It offers nothing ours lacks, so having two owners
|
||||||
|
would add a fight and gain nothing.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
- Live, read-only, `k3s-prod` ~06:25Z: the CRD schema field `spec.serviceMonitor.enabled`
|
||||||
|
(`default: true`) exists in both served versions. The live `keycloak` ServiceMonitor has exactly
|
||||||
|
**one** managedFields manager, `kustomize-controller` (Apply, 2026-09-05), **no ownerReferences**,
|
||||||
|
and labels `kustomize.toolkit.fluxcd.io/name=platform-config`, `release=kube-prometheus-stack`. So
|
||||||
|
the operator's apply never landed, and nothing on the object ties it to the Keycloak CR.
|
||||||
|
- The ServiceMonitor will not be deleted when the operator is switched off: the operator's dependent
|
||||||
|
informer is filtered on `Constants.DEFAULT_LABELS_AS_STRING`
|
||||||
|
(`app.kubernetes.io/managed-by=keycloak-operator`). Our object does not carry that label and has
|
||||||
|
no ownerReference, so the operator cannot see it as its secondary and cannot delete or GC it.
|
||||||
|
This conclusion comes from reading the code. It was not observed.
|
||||||
|
- The other operator dependents in ns `keycloak` do not collide with anything: `keycloak-service`,
|
||||||
|
`keycloak-discovery`, the StatefulSet `keycloak`, `keycloak-initial-admin`, and
|
||||||
|
`keycloak-network-policy` (owner Keycloak, no Flux label). The repo declares none of these names.
|
||||||
|
It only *references* `keycloak-service` by name (HTTPRoute, reconcilers). There are no PDBs in the
|
||||||
|
namespace, and the Ingress is already `enabled: false`. The only NetworkPolicy in the namespace is
|
||||||
|
the operator's. The repo has no Keycloak NetworkPolicy, and no Kyverno-generated default-deny is
|
||||||
|
present there. NetworkPolicies add up, so the operator's allow-policy cannot fight another owner.
|
||||||
|
Left at its default.
|
||||||
|
- Local gate: see the PR body. It records the rc and stages.
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- That the CR reaches `Ready=True`/`HasErrors=False` after merge. The status also says
|
||||||
|
"Performing Keycloak upgrade, scaling down the deployment". I am inferring that this half clears
|
||||||
|
once the workflow stops erroring. It is not proven.
|
||||||
|
- That the operator stops issuing the PATCH once `enabled: false` is applied. Watch the operator
|
||||||
|
logs for 409.
|
||||||
|
- That `platform-config` and its downstream Kustomizations go Ready on the new revision, and that
|
||||||
|
Prometheus keeps exactly one `serviceMonitor/keycloak/keycloak/0` pool with targets up.
|
||||||
|
- The operator's actual label set for `DEFAULT_LABELS_AS_STRING`. I took it from the observed labels
|
||||||
|
on operator-created objects, not from `Constants.java`.
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
---
|
|
||||||
date: 2026-11-08
|
|
||||||
branch: feat/parallel-l0-checks
|
|
||||||
title: L0 offline checks, mutation suites and yamllint run in bounded parallel pools (gate 184s -> 32s locally)
|
|
||||||
---
|
|
||||||
|
|
||||||
## What changed
|
|
||||||
|
|
||||||
The gate's wall time was dominated by serial loops over independent subprocesses. Three of
|
|
||||||
them now run in bounded pools; none of them changes a verdict, an exit code, or the order of
|
|
||||||
anything printed.
|
|
||||||
|
|
||||||
- `conformance/lib/run_l1_offline.py` — the per-check loop (L0 and L1 offline checks) runs in
|
|
||||||
a `ThreadPoolExecutor` of `bash <check>` children. `--jobs N` / env `CONF_OFFLINE_JOBS`,
|
|
||||||
default `min(8, usable CPUs)` (`sched_getaffinity`, so a cgroup cpuset is respected);
|
|
||||||
`--jobs 1` is the old serial runner. Results are consumed in SUBMISSION order, so `cases`,
|
|
||||||
the JUnit, the step summary and the 0/1/3 aggregation are built from exactly the same
|
|
||||||
ordered list as before. Additive output only: one `[offline] running N check(s), jobs=…`
|
|
||||||
line, and a trailing `[N.Ns]` wall time on each per-check status line. Children get
|
|
||||||
`stdin=/dev/null` (parallel children must not share the runner's stdin).
|
|
||||||
- `conformance/tests/l0-static/keycloak-{passkey,client-secret}-reconciler/mutations.py` —
|
|
||||||
pass 1 (serial) checks every anchor and builds each mutated source, exactly as before;
|
|
||||||
pass 2 runs the `--selftest` children in a pool (env `MUTATION_JOBS`, default
|
|
||||||
`min(8, CPUs)`, `1` = serial). Verdicts are consumed in `MUTATIONS` order. A timeout
|
|
||||||
(`TimeoutExpired`) still propagates and fails the check; queued mutations are cancelled.
|
|
||||||
An invalid `MUTATION_JOBS` exits 2 (fails the check, never skips it).
|
|
||||||
- **Nested-pool budget.** When the outer runner is parallel it exports
|
|
||||||
`MUTATION_JOBS = max(2, min(8, (cpus - jobs + 2) // 2))` unless the caller already set
|
|
||||||
one. Reason: only those two checks fan out; the other `jobs-2` workers are single
|
|
||||||
processes, so the two suites split the CPUs those leave free. Peak processes ≈
|
|
||||||
`(jobs-2) + 2*inner` ≤ cpus whenever cpus ≥ jobs+2 (24 CPUs: 6 + 16 = 22), and ~1.25–1.5×
|
|
||||||
cpus on a small runner (4 CPUs: 2 + 4 = 6). The floor of 2 is deliberate: a 1-wide inner
|
|
||||||
pool would make the 40-mutation passkey suite (~85s serial) the critical path of the stage.
|
|
||||||
- `ci/lint.sh` — yamllint runs over `LINT_JOBS` (default `min(8, nproc)`) CONTIGUOUS slices
|
|
||||||
of the sorted file list concurrently; each slice's output is buffered to its own file and
|
|
||||||
printed in slice order, so the text is the same as one serial call and a `file` header is
|
|
||||||
never separated from its `line:col` findings. Stage rc = the highest slice rc; a slice that
|
|
||||||
leaves no rc file counts as 1. Invalid `LINT_JOBS` exits 2.
|
|
||||||
|
|
||||||
### Parallel-safety audit (all 25 `l0-static/*/check.sh`, 3 `l1-rendering/*/render-check.sh`)
|
|
||||||
|
|
||||||
Grepped every check for fixed `/tmp` paths, `_artifacts`/`ARTIFACTS`/`COVERAGE_OUT`,
|
|
||||||
`write_text`/`open(…,"w")`, `mktemp`/`mkdtemp`/`TemporaryDirectory`, sockets/ports, `git`
|
|
||||||
writes and `run.sh` invocations, and read what each writer writes to:
|
|
||||||
|
|
||||||
- **Scratch state: all private.** Every writer uses its own `mktemp -d` / `mkdtemp` /
|
|
||||||
`TemporaryDirectory` (placeholder-guard-coupling, spec-immutability-enforced,
|
|
||||||
whole-repo-invariants-wired, auditability-proofs-producible, alert-rules-unit-tested and its
|
|
||||||
`platform/monitoring/tests/run.sh`, internal-services-publish-no-public-dns,
|
|
||||||
oauth2-proxy-{api-routes-per-app,cookie-names-distinct}, both keycloak reconcilers, the three
|
|
||||||
L1 render checks). No fixed `/tmp` path anywhere.
|
|
||||||
- **Git writes:** only spec-immutability-enforced, and only `git init/add/commit` inside its
|
|
||||||
own `mktemp` repo. Everything else is `git ls-files`/`ls-tree`/`log`/`rev-parse` (read-only).
|
|
||||||
- **Ports:** the reconciler selftests bind `127.0.0.1:0` (kernel-assigned); the passkey
|
|
||||||
selftest also dials `127.0.0.1:1` as a deliberate refused connection. No fixed listener.
|
|
||||||
- **Ansible** (dev-nodes-provisioned-as-code): `--syntax-check`/`--list-hosts` only,
|
|
||||||
`retry_files_enabled = False`; its local tmp is per-process.
|
|
||||||
- **The one shared writer:** auditability-proofs-producible runs
|
|
||||||
`./run.sh --coverage-only --requirements …`, which REWRITES
|
|
||||||
`conformance/_artifacts/coverage/coverage.{json,md}` (it inherits `CONF_ARTIFACTS`). That was
|
|
||||||
already true serially. No other offline check reads or writes that directory (the only
|
|
||||||
reader is `tests/meta/harness-selfcheck`, not an offline check), and the outer runner
|
|
||||||
writes coverage BEFORE and the JUnit AFTER the pool, so the final state is the same as
|
|
||||||
serial. Not serialised; left as is and recorded here. `lib/status.py` (run by that check)
|
|
||||||
reads `_artifacts/report.junit.xml` as an advisory — written only after the pool, unchanged.
|
|
||||||
- whole-repo-invariants-wired calls `run.sh --print-selection` (writes nothing) and the
|
|
||||||
runner itself against synthetic dirs in its own `mktemp`.
|
|
||||||
|
|
||||||
Result: no check needed fixing or serialising. A new check that needs shared state must say
|
|
||||||
so; the runner's docstring now states that contract.
|
|
||||||
|
|
||||||
## What I verified
|
|
||||||
|
|
||||||
All in the pinned toolbox (`dev.sh run`, `DEV_KUBECONFIG=/dev/null`, ENGINE shim adding a ro
|
|
||||||
mount of the main checkout's `.git`; spec repo resolved as a sibling, spec-immutability
|
|
||||||
`pass`), host 24 CPUs, every `dev.sh` call under `flock`.
|
|
||||||
|
|
||||||
- **Gate, before** (origin/main `1a33814`, detached scratch worktree + spec mount):
|
|
||||||
`GATE_RC=3`, in-container wall **183.9s**; `l0` stage **168.3s**, `lint` **7.8s**.
|
|
||||||
"Stages that did not run: gitea-settings" (plus the expected on-base
|
|
||||||
touched-set-not-determinable note, since main has no branch commits).
|
|
||||||
- **Gate, after** (this branch, commit `a775082`): `GATE_RC=3`, in-container wall **32.1s**;
|
|
||||||
`l0` **21.1s**, `lint` **2.4s**, `l1` 2.3s. "Stages that did not run: gitea-settings" — the
|
|
||||||
only not-run stage. **rc 3, not green.** Caveat: the branch run selected 23 L0 checks (the
|
|
||||||
touched-requirement scoping dropped dev-nodes-provisioned-as-code and
|
|
||||||
placeholder-guard-coupling), the main run 25.
|
|
||||||
- **Same-tree L0 comparison** (all 25 l0 dirs, same tree, same container):
|
|
||||||
`CONF_OFFLINE_JOBS=1 MUTATION_JOBS=1` **164.0s / 165.3s** vs default (jobs=8,
|
|
||||||
MUTATION_JOBS=8) **19.2s / 18.7s**. JUnit `cmp`-identical, step summary `cmp`-identical,
|
|
||||||
stderr identical except the timing suffixes and the JUnit path in the last line.
|
|
||||||
- **Mutation suites, host:** passkey 83.2s → 10.5s (`MUTATION_JOBS=8`), client-secret
|
|
||||||
11.2s → 2.0s; output of `MUTATION_JOBS=1` and `=8` byte-identical to the pre-change script's,
|
|
||||||
rc 0 all round.
|
|
||||||
- **Falsification (not committed):** in a scratch worktree at `a775082`, made the passkey
|
|
||||||
mutation "the protected-subtree screen is never called" a no-op (replacement == anchor).
|
|
||||||
Host: `MUTATION_JOBS=1` and `=8` both rc 1, output byte-identical (with a second, broken
|
|
||||||
anchor also injected: both reported). Toolbox: runner rc 1 both serial and parallel,
|
|
||||||
`L0 FAIL keycloak-passkey-reconciler … 1 of 40 mutation(s) not proven … SURVIVED`; full
|
|
||||||
gate **rc 1**, `stage l0: FAIL`, `MERGE GATE: BLOCKED`. Reverted; scratch worktree removed.
|
|
||||||
- **lint.sh, host scratch repo** (31 YAML files, two broken): `LINT_JOBS=1/4/8` all rc 1,
|
|
||||||
stdout identical, findings named `y/f07.yaml` / `2:1 … key-duplicates` etc.; clean tree rc 0;
|
|
||||||
`LINT_JOBS=x` rc 2.
|
|
||||||
|
|
||||||
## What I did NOT verify
|
|
||||||
|
|
||||||
- **CI timings at the time of writing.** The PR's CI run is measured after push and reported
|
|
||||||
on the PR / handoff message, not here. The CI runner's CPU count is UNVERIFIED — the new
|
|
||||||
`[offline] running … jobs=… cpus=…` line in the CI log is what will show it. On a small
|
|
||||||
runner the speedup will be smaller than locally.
|
|
||||||
- **approval-policy** will be red on CI: `ci/**` and `conformance/**` are sensitive paths and
|
|
||||||
need the operator's review. I did not and must not override it.
|
|
||||||
- Flakiness under load: the selftests have no wall-clock assertions I found, and 4 toolbox
|
|
||||||
runs + several host runs were all stable, but that is a handful of runs, not a soak.
|
|
||||||
- The coverage files under `conformance/_artifacts/coverage/` end in the same state as before
|
|
||||||
only because a single check rewrites them; I did not change that pre-existing quirk.
|
|
||||||
- L2/L3 conformance: not run (no cluster in CI, as before).
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
date: 2026-11-08
|
||||||
|
branch: rework/136
|
||||||
|
title: "PR #136 rework: regenerate L1 render goldens for the oauth2-proxy v7.15.6 sidecar bump"
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
- Renovate PR #136 bumps `OAUTH2_PROXY_IMAGE` in `catalog/render.py` (and the generated
|
||||||
|
`apps/wave-*` / `platform/crafty` pins) from `v7.8.1` to `v7.15.6`. Its gate failed at L1
|
||||||
|
because three byte-for-byte render goldens still carried the old tag:
|
||||||
|
- `conformance/tests/l1-rendering/container-workload-render/golden/expected.yaml`
|
||||||
|
- `conformance/tests/l1-rendering/egress-class-render/golden/egress-vpn.expected.yaml`
|
||||||
|
- `conformance/tests/l1-rendering/egress-class-render/golden/egress-internet.expected.yaml`
|
||||||
|
(CI only reported the vpn one because `render-check.sh` stops at the first failing diff;
|
||||||
|
the internet golden had drifted too.)
|
||||||
|
- Regenerated all three in the pinned toolbox with the exact invocation the check scripts use:
|
||||||
|
`python3 catalog/render.py <fixture> > <golden>`. The diff in each is **one line**:
|
||||||
|
`image: quay.io/oauth2-proxy/oauth2-proxy:v7.8.1` -> `...:v7.15.6`. Nothing else moved.
|
||||||
|
- `platform/crafty/README.md`: the "Sidecar" row described the current pin; updated to v7.15.6.
|
||||||
|
- Deliberately NOT changed (they record history, not the current pin):
|
||||||
|
- `catalog/render.py` comments / assertion messages saying a flag was "confirmed present in the
|
||||||
|
PINNED v7.8.1 binary" or that `--api-routes` "does not exist in v7.8.1". Rewriting them to
|
||||||
|
v7.15.6 would assert a `--help` verification nobody has done against v7.15.6.
|
||||||
|
- `conformance/tests/l0-static/oauth2-proxy-api-routes-per-app/check.sh`, the schema
|
||||||
|
description, `runbooks/oauth2-proxy-session.md`, `docs/plans/...` — same reasoning.
|
||||||
|
- `platform/oauth2-proxy/auth-visibility-alerts.promtool-test.yaml` — v7.8.1 appears in
|
||||||
|
synthetic kube-state-metrics series (test input data), not as a pin.
|
||||||
|
- `.gitea/workflows/renovate.yaml` comment — a dated measurement of the pre-Renovate state.
|
||||||
|
- Branch was 55 commits behind `origin/main`, but main touches neither `catalog/render.py` nor
|
||||||
|
the L1 goldens, and the merge is conflict-free; no merge of main was needed for the gate.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
- The golden diff is exactly the image tag in each of the three files (`git diff -U0`).
|
||||||
|
- No remaining `oauth2-proxy:v7.8.1` in any rendered/generated/golden file (`git grep`).
|
||||||
|
- Local toolbox gate result: see the PR #136 comment posted with this push (rc and per-stage
|
||||||
|
results are quoted there and in the worker report).
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- That the v7.15.6 sidecar actually starts and authenticates in the cluster with the rendered
|
||||||
|
args. Earlier research said no flag used here was removed/renamed between 7.8.1 and 7.15.6;
|
||||||
|
I did not re-run `oauth2-proxy --help` against the v7.15.6 image.
|
||||||
|
- L2/L3 conformance (does not run in CI: `CLUSTER_AVAILABLE` unset).
|
||||||
|
- `approval-policy`: `conformance/**` is a sensitive path, so this PR is CI-red until the
|
||||||
|
operator posts `/approve-override <reason>` as a PR review. The local gate cannot evaluate it.
|
||||||
|
- Whether Renovate keeps rebasing this branch now that it carries a non-Renovate commit.
|
||||||
@@ -16,7 +16,7 @@ Cilium L2 LoadBalancer (game protocols authenticate players themselves).
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Image | `registry.gitlab.com/crafty-controller/crafty-4:4.10.8` (current stable; bare-semver tags, no `v`) |
|
| Image | `registry.gitlab.com/crafty-controller/crafty-4:4.10.8` (current stable; bare-semver tags, no `v`) |
|
||||||
| Web UI | HTTPS **self-signed** on `:8443` (reached only via the sidecar / same-pod) |
|
| Web UI | HTTPS **self-signed** on `:8443` (reached only via the sidecar / same-pod) |
|
||||||
| Sidecar | `quay.io/oauth2-proxy/oauth2-proxy:v7.8.1`, reverse-proxy on `:4180` |
|
| Sidecar | `quay.io/oauth2-proxy/oauth2-proxy:v7.15.6`, reverse-proxy on `:4180` |
|
||||||
| Data | ONE Longhorn RWO PVC `crafty-data` (20Gi), mounted 5× by subPath |
|
| Data | ONE Longhorn RWO PVC `crafty-data` (20Gi), mounted 5× by subPath |
|
||||||
| PVC subPaths | `servers`→`/crafty/servers`, `backups`→`/crafty/backups`, `logs`→`/crafty/logs`, `import`→`/crafty/import`, `config`→`/crafty/app/config` |
|
| PVC subPaths | `servers`→`/crafty/servers`, `backups`→`/crafty/backups`, `logs`→`/crafty/logs`, `import`→`/crafty/import`, `config`→`/crafty/app/config` |
|
||||||
| Identity | `runAsUser:1000 runAsGroup:0 fsGroup:0 runAsNonRoot:true` (see below) |
|
| Identity | `runAsUser:1000 runAsGroup:0 fsGroup:0 runAsNonRoot:true` (see below) |
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ spec:
|
|||||||
- name: oauth2-proxy
|
- name: oauth2-proxy
|
||||||
# quay.io/oauth2-proxy/oauth2-proxy — same authenticator family as platform/oauth2-proxy, but
|
# quay.io/oauth2-proxy/oauth2-proxy — same authenticator family as platform/oauth2-proxy, but
|
||||||
# here in per-app REVERSE-PROXY mode (--upstream is the co-located Crafty UI, not static://202).
|
# here in per-app REVERSE-PROXY mode (--upstream is the co-located Crafty UI, not static://202).
|
||||||
image: quay.io/oauth2-proxy/oauth2-proxy:v7.8.1
|
image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.6
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
args:
|
args:
|
||||||
- --http-address=0.0.0.0:4180
|
- --http-address=0.0.0.0:4180
|
||||||
|
|||||||
@@ -38,6 +38,20 @@ spec:
|
|||||||
ingress:
|
ingress:
|
||||||
enabled: false # exposed via Gateway API HTTPRoute (httproute.yaml), not the
|
enabled: false # exposed via Gateway API HTTPRoute (httproute.yaml), not the
|
||||||
# operator's built-in Ingress
|
# operator's built-in Ingress
|
||||||
|
# --- ONE OWNER for the `keycloak` ServiceMonitor: this repo (servicemonitor.yaml), not the operator.
|
||||||
|
# Since 26.x the operator creates its OWN ServiceMonitor, named after this CR (`keycloak`, i.e.
|
||||||
|
# the SAME name/namespace as ours), whenever metrics-enabled=true and the CRD exists —
|
||||||
|
# spec.serviceMonitor.enabled defaults TRUE. On 2026-11-08, right after the 26.0.6 -> 26.8.2
|
||||||
|
# bump, its server-side apply onto our Flux-owned object failed `409 Conflict`, the operator
|
||||||
|
# exhausted retries, the CR sat Ready=False/HasErrors=True, and `platform-config` (wait: true,
|
||||||
|
# so it health-checks this CR) stalled every Kustomization downstream of it. Ours is kept
|
||||||
|
# because the L0 servicemonitor-ports-resolve and L2 keycloak-monitored conformance tests and
|
||||||
|
# the dashboard's up{namespace="keycloak"} are written against it; the operator's would scrape
|
||||||
|
# the same thing (management port 9000, /metrics, 30s). Do NOT flip this to true without
|
||||||
|
# deleting servicemonitor.yaml in the same change — two owners of one object is a fight
|
||||||
|
# (cf. the default-deny NetworkPolicy drift loop).
|
||||||
|
serviceMonitor:
|
||||||
|
enabled: false
|
||||||
# --- Hardening + observability options (docs/15). Brute-force detection is a REALM setting
|
# --- Hardening + observability options (docs/15). Brute-force detection is a REALM setting
|
||||||
# (bruteForceProtected in the realm import); these enable health + metrics endpoints. ---
|
# (bruteForceProtected in the realm import); these enable health + metrics endpoints. ---
|
||||||
additionalOptions:
|
additionalOptions:
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
# /metrics) once metrics-enabled/health-enabled are set (see keycloak-instance.yaml additionalOptions).
|
# /metrics) once metrics-enabled/health-enabled are set (see keycloak-instance.yaml additionalOptions).
|
||||||
# We select the operator-created keycloak-service (which selects the Keycloak pods) and scrape the pods'
|
# We select the operator-created keycloak-service (which selects the Keycloak pods) and scrape the pods'
|
||||||
# management port by number via targetPort.
|
# management port by number via targetPort.
|
||||||
|
# SOLE OWNER: the operator's own ServiceMonitor (same name `keycloak`) is switched OFF in
|
||||||
|
# keycloak-instance.yaml (spec.serviceMonitor.enabled: false) — 26.x defaults it ON and its apply
|
||||||
|
# 409-looped against this object on 2026-11-08. Keep exactly one of the two.
|
||||||
# TODO(WS-SEC, bring-up): confirm the management port number/name the operator exposes (9000 default)
|
# TODO(WS-SEC, bring-up): confirm the management port number/name the operator exposes (9000 default)
|
||||||
# and adjust targetPort if the pinned operator changes it. The KeycloakDown / KeycloakDatabaseDown
|
# and adjust targetPort if the pinned operator changes it. The KeycloakDown / KeycloakDatabaseDown
|
||||||
# alerts (platform/monitoring/alerts.yaml) key on kube-state-metrics and fire regardless of this SM.
|
# alerts (platform/monitoring/alerts.yaml) key on kube-state-metrics and fire regardless of this SM.
|
||||||
|
|||||||
Reference in new issue
Block a user