Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a69f6906a | ||
|
|
7ef2af205d | ||
|
|
788eaeae62 | ||
|
|
576e1abb19 | ||
|
|
a1e808e697 | ||
|
|
bcb8931b4f | ||
|
|
0d7f94098b | ||
|
|
5ffd0c98bc | ||
|
|
f0994c11a0 | ||
|
|
b52c8d1af6 | ||
|
|
1677dff568 |
No files matched your search
@@ -40,13 +40,21 @@ missing credential is a lock-out, here a skip would be a bypass.
|
|||||||
**The administrator override (2026-10-01).** The rule above was unsatisfiable for the one human
|
**The administrator override (2026-10-01).** The rule above was unsatisfiable for the one human
|
||||||
who runs this repo: Gitea refuses to let an author approve their own PR, and `jakman85` is the
|
who runs this repo: Gitea refuses to let an author approve their own PR, and `jakman85` is the
|
||||||
only collaborator — so a sensitive-path PR the operator opened could never be merged, *including
|
only collaborator — so a sensitive-path PR the operator opened could never be merged, *including
|
||||||
the PR that would fix that.* An administrator may therefore waive the review by commenting on
|
the PR that would fix that.* An administrator may therefore waive the review by posting this on
|
||||||
the PR:
|
the PR:
|
||||||
|
|
||||||
/approve-override <reason>
|
/approve-override <reason>
|
||||||
|
|
||||||
The directive must **start a line**, the **reason is mandatory**, the commenter's `admin`/`owner`
|
**Post it as a PR *review* of type "Comment", not in the plain comment box.** CI's
|
||||||
permission is re-checked **live** when the gate runs, and the comment must be **newer than the
|
`SETTINGS_API_TOKEN` is minted `[read:repository, read:admin]`, and `/issues/{n}/comments` sits
|
||||||
|
behind Gitea's *issue* scope — so an override typed into the comment box is **invisible to CI**,
|
||||||
|
and the stage reports it as a missing approval rather than as a permission problem (measured on
|
||||||
|
PR #38, 2026-10-02). `/pulls/{n}/reviews` is readable, so a review body works today. Both sources
|
||||||
|
are accepted and the gate names which it used; the comment box starts working once
|
||||||
|
`infra/gitea/set-ci-token.sh` is re-run to pick up `read:issue`.
|
||||||
|
|
||||||
|
The directive must **start a line**, the **reason is mandatory**, the poster's `admin`/`owner`
|
||||||
|
permission is re-checked **live** when the gate runs, and it must be **newer than the
|
||||||
head commit** — push again and you must override again. The overrider, the timestamp and the
|
head commit** — push again and you must override again. The overrider, the timestamp and the
|
||||||
reason are printed in the gate log and the job summary. For an administrator, "a change to the
|
reason are printed in the gate log and the job summary. For an administrator, "a change to the
|
||||||
gate cannot be gated by the gate" is therefore **advisory, not structural**; what is kept is an
|
gate cannot be gated by the gate" is therefore **advisory, not structural**; what is kept is an
|
||||||
|
|||||||
+105
-50
@@ -1,39 +1,54 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""ci/approval-override.py — select the admin-override comment on a PR, if there is a valid one.
|
"""ci/approval-override.py — select the admin-override on a PR, if there is a valid one.
|
||||||
|
|
||||||
This script decides NOTHING about authority. It parses a PR's comment list and returns the
|
This script decides NOTHING about authority. It parses a PR's comments and reviews and returns
|
||||||
single override *candidate* that ci/approval-policy.sh should then try to authorise against
|
the single override *candidate* that ci/approval-policy.sh should then try to authorise against
|
||||||
the live Gitea permission API. Keeping the two apart is the point:
|
the live Gitea permission API. Keeping the two apart is the point:
|
||||||
|
|
||||||
* parsing is pure, offline, and therefore TESTABLE (ci/tests/approval-override.test.sh);
|
* parsing is pure, offline, and therefore TESTABLE (ci/tests/approval-override.test.sh);
|
||||||
* authorisation needs the API and must stay in the stage that fails closed.
|
* authorisation needs the API and must stay in the stage that fails closed.
|
||||||
|
|
||||||
A candidate is a comment whose body contains, AT THE START OF A LINE, the directive followed
|
A candidate is a comment OR a review whose body contains, AT THE START OF A LINE, the directive
|
||||||
by a non-empty reason:
|
followed by a non-empty reason:
|
||||||
|
|
||||||
/approve-override the gate cannot gate itself and I am the only human reviewer
|
/approve-override the gate cannot gate itself and I am the only human reviewer
|
||||||
|
|
||||||
|
TWO SOURCES, AND WHY THAT IS NOT BELT-AND-BRACES. Measured 2026-10-02 on PR #38: the override was
|
||||||
|
posted as an ordinary PR comment, was well-formed, was newer than the head commit — and the stage
|
||||||
|
still blocked, because CI could not READ it. `SETTINGS_API_TOKEN` is minted with scopes
|
||||||
|
`[read:repository, read:admin]`, and Gitea puts /issues/{n}/comments behind the *issue* scope, so
|
||||||
|
the request came back as an error OBJECT rather than an array. `/pulls/{n}/reviews` is under
|
||||||
|
read:repository and was readable in the same run. So:
|
||||||
|
|
||||||
|
* a REVIEW body works with the token CI has today (leave a review of type "Comment");
|
||||||
|
* a COMMENT body works once the token carries read:issue (see infra/gitea/set-ci-token.sh).
|
||||||
|
|
||||||
|
Accepting both means the hatch is not hostage to one API scope. A source that cannot be read
|
||||||
|
contributes no candidates and is reported on stderr; it can only cause a false BLOCK, never a
|
||||||
|
false pass, which is why degrading it is safe and blocking on it was not necessary.
|
||||||
|
|
||||||
WHY EACH RULE IS HERE
|
WHY EACH RULE IS HERE
|
||||||
|
|
||||||
* START OF A LINE, not "anywhere in the body". Otherwise quoting someone else's override
|
* START OF A LINE, not "anywhere in the body". Otherwise quoting someone else's override
|
||||||
("you could just /approve-override this") re-arms it. Markdown quotes are stripped of a
|
("you could just /approve-override this") re-arms it. Lines beginning with '>' are DROPPED
|
||||||
leading '>' before matching for the same reason -- a quoted directive must NOT count, so
|
rather than unquoted, for exactly that reason.
|
||||||
lines beginning with '>' are DROPPED, not unquoted.
|
* A REASON IS MANDATORY. A bare directive records that a review was waived but not why, which
|
||||||
* A REASON IS MANDATORY. A bare directive records that a review was waived but not why,
|
is the difference between an audit trail and a rubber stamp.
|
||||||
which is the difference between an audit trail and a rubber stamp.
|
* STALE OVERRIDES ARE DISCARDED. An override earned at commit A must not silently cover commit
|
||||||
* STALE OVERRIDES ARE DISCARDED. An override earned at commit A must not silently cover
|
B. Any candidate created at or before the head commit's timestamp is dropped — the same
|
||||||
commit B. Any candidate created at or before the head commit's timestamp is dropped --
|
semantics Gitea's own `dismiss_stale_approvals` applies to approvals. Push new commits and
|
||||||
the same semantics Gitea's own `dismiss_stale_approvals` applies to reviews. Push new
|
you must re-override.
|
||||||
commits and you must re-override.
|
* A PENDING REVIEW IS NOT SUBMITTED. It is a private draft visible only to its author, so it is
|
||||||
* THE LATEST CANDIDATE WINS, so a second override comment supersedes the first rather than
|
not an act of anything and is dropped.
|
||||||
both being live.
|
* THE LATEST CANDIDATE WINS, across both sources, so a later override supersedes an earlier one.
|
||||||
|
|
||||||
Output: one TSV line (login, created_at, reason) on stdout, or nothing at all when there is no
|
Output: one TSV line (login, timestamp, source, reason) on stdout, or nothing at all when there is
|
||||||
valid candidate. Never exits non-zero for "no override" -- absence is a normal answer, and the
|
no valid candidate. Never exits non-zero for "no override" — absence is a normal answer and the
|
||||||
caller must not be able to confuse it with a parse crash.
|
caller must not be able to confuse it with a parse crash.
|
||||||
|
|
||||||
Input, all via env so nothing lands in `ps`:
|
Input, all via env so nothing lands in `ps`:
|
||||||
COMMENTS_JSON the Gitea /issues/{n}/comments array
|
COMMENTS_JSON the Gitea /issues/{n}/comments array (or an error object; or absent)
|
||||||
|
REVIEWS_JSON the Gitea /pulls/{n}/reviews array (or an error object; or absent)
|
||||||
HEAD_TIME ISO-8601 timestamp of the PR head commit
|
HEAD_TIME ISO-8601 timestamp of the PR head commit
|
||||||
DIRECTIVE the override directive, e.g. /approve-override
|
DIRECTIVE the override directive, e.g. /approve-override
|
||||||
"""
|
"""
|
||||||
@@ -81,57 +96,97 @@ def extract_reason(body, directive):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def load_source(env_name, label):
|
||||||
directive = os.environ.get("DIRECTIVE", "/approve-override").strip()
|
"""Return a list of items for one source, or [] with a stderr note if it is unusable."""
|
||||||
head_time = parse_ts(os.environ.get("HEAD_TIME", ""))
|
raw = os.environ.get(env_name)
|
||||||
|
if not raw or not raw.strip():
|
||||||
|
return []
|
||||||
try:
|
try:
|
||||||
comments = json.loads(os.environ.get("COMMENTS_JSON") or "[]")
|
parsed = json.loads(raw)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
# The caller validated this JSON already; if it is broken here, say so loudly on
|
print(
|
||||||
# stderr and emit no candidate. Silence on stdout means "no override".
|
"approval-override: %s is not valid JSON — ignoring that source" % label,
|
||||||
print("approval-override: COMMENTS_JSON is not valid JSON", file=sys.stderr)
|
file=sys.stderr,
|
||||||
return 0
|
)
|
||||||
if not isinstance(comments, list):
|
return []
|
||||||
print("approval-override: COMMENTS_JSON is not a list", file=sys.stderr)
|
if isinstance(parsed, list):
|
||||||
return 0
|
return parsed
|
||||||
|
# An error object, typically {"message": "token does not have ... scope"}. Say which,
|
||||||
|
# because "no override found" and "could not read the overrides" look identical from
|
||||||
|
# the outside and the first wasted a real debugging session.
|
||||||
|
detail = ""
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
detail = str(parsed.get("message") or parsed.get("errors") or "")[:200]
|
||||||
|
print(
|
||||||
|
"approval-override: %s is not a list (Gitea returned %s%s) — ignoring that source"
|
||||||
|
% (label, type(parsed).__name__, ": " + detail if detail else ""),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
candidates = []
|
|
||||||
for comment in comments:
|
def candidates_from(items, source, directive, head_time):
|
||||||
if not isinstance(comment, dict):
|
"""Yield (when, login, reason, raw_when, source) for every valid override in one source."""
|
||||||
|
found = []
|
||||||
|
for item in items:
|
||||||
|
if not isinstance(item, dict):
|
||||||
continue
|
continue
|
||||||
login = ((comment.get("user") or {}).get("login") or "").strip()
|
login = ((item.get("user") or {}).get("login") or "").strip()
|
||||||
reason = extract_reason(comment.get("body"), directive)
|
reason = extract_reason(item.get("body"), directive)
|
||||||
if not login or not reason:
|
if not login or not reason:
|
||||||
continue
|
continue
|
||||||
created_raw = comment.get("created_at") or comment.get("updated_at") or ""
|
# A PENDING review is an unsubmitted private draft — not an act of anything.
|
||||||
created = parse_ts(created_raw)
|
state = str(item.get("state") or "").upper()
|
||||||
if created is None:
|
if source == "review" and state == "PENDING":
|
||||||
print(
|
print(
|
||||||
"approval-override: dropping override by '%s' — unreadable timestamp %r"
|
"approval-override: dropping PENDING (unsubmitted) review by '%s'" % login,
|
||||||
% (login, created_raw),
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
raw_when = (
|
||||||
|
item.get("submitted_at") or item.get("created_at") or item.get("updated_at") or ""
|
||||||
|
)
|
||||||
|
when = parse_ts(raw_when)
|
||||||
|
if when is None:
|
||||||
|
print(
|
||||||
|
"approval-override: dropping %s override by '%s' — unreadable timestamp %r"
|
||||||
|
% (source, login, raw_when),
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
# An override must post-date the commit it covers.
|
# An override must post-date the commit it covers.
|
||||||
if head_time is not None and created <= head_time:
|
if head_time is not None and when <= head_time:
|
||||||
print(
|
print(
|
||||||
"approval-override: dropping STALE override by '%s' (%s) — not newer than "
|
"approval-override: dropping STALE %s override by '%s' (%s) — not newer than "
|
||||||
"head commit (%s). Re-comment to override the current commit."
|
"head commit (%s). Re-post it to override the current commit."
|
||||||
% (login, created.isoformat(), head_time.isoformat()),
|
% (source, login, when.isoformat(), head_time.isoformat()),
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
candidates.append((created, login, reason, created_raw))
|
found.append((when, login, reason, raw_when, source))
|
||||||
|
return found
|
||||||
|
|
||||||
if not candidates:
|
|
||||||
|
def main():
|
||||||
|
directive = os.environ.get("DIRECTIVE", "/approve-override").strip()
|
||||||
|
head_time = parse_ts(os.environ.get("HEAD_TIME", ""))
|
||||||
|
|
||||||
|
found = []
|
||||||
|
found += candidates_from(
|
||||||
|
load_source("COMMENTS_JSON", "COMMENTS_JSON"), "comment", directive, head_time
|
||||||
|
)
|
||||||
|
found += candidates_from(
|
||||||
|
load_source("REVIEWS_JSON", "REVIEWS_JSON"), "review", directive, head_time
|
||||||
|
)
|
||||||
|
|
||||||
|
if not found:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# Latest wins: a later override supersedes an earlier one.
|
# Latest wins, across both sources: a later override supersedes an earlier one.
|
||||||
created, login, reason, created_raw = max(candidates, key=lambda c: c[0])
|
when, login, reason, raw_when, source = max(found, key=lambda c: c[0])
|
||||||
# Keep the reason on one line — this becomes a TSV field.
|
# Keep the reason on one line — this becomes a TSV field.
|
||||||
reason = " ".join(reason.split())
|
reason = " ".join(reason.split())
|
||||||
print("%s\t%s\t%s" % (login, created_raw, reason))
|
print("%s\t%s\t%s\t%s" % (login, raw_when, source, reason))
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+34
-8
@@ -103,8 +103,15 @@ REVIEWS_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/pulls/${PR}/reviews
|
|||||||
# PR conversation comments live under the ISSUES endpoint. `/pulls/{n}/comments` returns review
|
# PR conversation comments live under the ISSUES endpoint. `/pulls/{n}/comments` returns review
|
||||||
# (diff) comments ONLY, so an override posted in the normal comment box would never appear in it.
|
# (diff) comments ONLY, so an override posted in the normal comment box would never appear in it.
|
||||||
# Verified against this Gitea (1.24.3) rather than assumed.
|
# Verified against this Gitea (1.24.3) rather than assumed.
|
||||||
|
#
|
||||||
|
# This one is NOT in the must-parse loop below, and that is deliberate. /issues/{n}/comments sits
|
||||||
|
# behind Gitea's *issue* token scope, which `SETTINGS_API_TOKEN` does not carry (it is minted
|
||||||
|
# `[read:repository, read:admin]`) — so it returns an error OBJECT, not an array. Blocking on that
|
||||||
|
# turned "CI cannot read your override" into a message that said "you did not post one", and cost a
|
||||||
|
# real debugging session on PR #38. It is handled explicitly, and loudly, further down: an
|
||||||
|
# unreadable source can only cause a false BLOCK, never a false pass.
|
||||||
COMMENTS_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/issues/${PR}/comments?limit=1000" || true)"
|
COMMENTS_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/issues/${PR}/comments?limit=1000" || true)"
|
||||||
for v in FILES_JSON PR_JSON REVIEWS_JSON COMMENTS_JSON; do
|
for v in FILES_JSON PR_JSON REVIEWS_JSON; do
|
||||||
jq -e . >/dev/null 2>&1 <<<"${!v}" || {
|
jq -e . >/dev/null 2>&1 <<<"${!v}" || {
|
||||||
err "could not read ${v} from Gitea for PR #${PR} — BLOCKING rather than assuming."
|
err "could not read ${v} from Gitea for PR #${PR} — BLOCKING rather than assuming."
|
||||||
summary "- approval-policy: **BLOCKED — Gitea unreadable**"
|
summary "- approval-policy: **BLOCKED — Gitea unreadable**"
|
||||||
@@ -170,8 +177,23 @@ if [ "$OVERRIDE_ENABLED" = "true" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Pure, offline, unit-tested selection: which comment, if any, is a live override?
|
# Is the comments source actually readable? Say so plainly if not — a scope problem and "you
|
||||||
OVERRIDE_TSV="$(COMMENTS_JSON="$COMMENTS_JSON" HEAD_TIME="$HEAD_TIME" DIRECTIVE="$DIRECTIVE" \
|
# never posted an override" are indistinguishable from the outside, and conflating them is what
|
||||||
|
# made PR #38 look like a code bug.
|
||||||
|
if ! jq -e 'type == "array"' >/dev/null 2>&1 <<<"${COMMENTS_JSON:-}"; then
|
||||||
|
SCOPE_MSG="$(jq -r '.message // empty' <<<"${COMMENTS_JSON:-null}" 2>/dev/null || true)"
|
||||||
|
warn "cannot read PR COMMENTS from Gitea${SCOPE_MSG:+ — ${SCOPE_MSG}}."
|
||||||
|
warn " /issues/${PR}/comments needs the *issue* token scope; SETTINGS_API_TOKEN is minted"
|
||||||
|
warn " [read:repository, read:admin] (infra/gitea/set-ci-token.sh). An override posted as an"
|
||||||
|
warn " ordinary PR COMMENT is therefore invisible to CI — post it as a REVIEW of type"
|
||||||
|
warn " 'Comment' instead, which /pulls/${PR}/reviews exposes under read:repository."
|
||||||
|
summary "- approval-policy: note — PR comments unreadable (token lacks the issue scope); use a **review** to override"
|
||||||
|
COMMENTS_JSON='[]'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Pure, offline, unit-tested selection: which comment or review, if any, is a live override?
|
||||||
|
OVERRIDE_TSV="$(COMMENTS_JSON="$COMMENTS_JSON" REVIEWS_JSON="$REVIEWS_JSON" \
|
||||||
|
HEAD_TIME="$HEAD_TIME" DIRECTIVE="$DIRECTIVE" \
|
||||||
python3 "${CI_DIR}/approval-override.py")" || {
|
python3 "${CI_DIR}/approval-override.py")" || {
|
||||||
err "ci/approval-override.py failed — BLOCKING rather than assuming there is no override."
|
err "ci/approval-override.py failed — BLOCKING rather than assuming there is no override."
|
||||||
summary "- approval-policy: **BLOCKED — override parser failed**"
|
summary "- approval-policy: **BLOCKED — override parser failed**"
|
||||||
@@ -180,7 +202,8 @@ if [ "$OVERRIDE_ENABLED" = "true" ]; then
|
|||||||
if [ -n "$OVERRIDE_TSV" ]; then
|
if [ -n "$OVERRIDE_TSV" ]; then
|
||||||
OV_USER="$(cut -f1 <<<"$OVERRIDE_TSV")"
|
OV_USER="$(cut -f1 <<<"$OVERRIDE_TSV")"
|
||||||
OV_WHEN="$(cut -f2 <<<"$OVERRIDE_TSV")"
|
OV_WHEN="$(cut -f2 <<<"$OVERRIDE_TSV")"
|
||||||
OV_WHY="$(cut -f3- <<<"$OVERRIDE_TSV")"
|
OV_SRC="$(cut -f3 <<<"$OVERRIDE_TSV")"
|
||||||
|
OV_WHY="$(cut -f4- <<<"$OVERRIDE_TSV")"
|
||||||
|
|
||||||
# AUTHORITY IS MEASURED LIVE, never inferred from the comment or from a name in git.
|
# AUTHORITY IS MEASURED LIVE, never inferred from the comment or from a name in git.
|
||||||
PERM_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/collaborators/${OV_USER}/permission" || true)"
|
PERM_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/collaborators/${OV_USER}/permission" || true)"
|
||||||
@@ -214,12 +237,13 @@ print("yes" if (ok_perm and ok_list) else "no")
|
|||||||
else
|
else
|
||||||
warn "ADMIN OVERRIDE by '${OV_USER}' (permission '${OV_PERM}'); author is '${AUTHOR}'."
|
warn "ADMIN OVERRIDE by '${OV_USER}' (permission '${OV_PERM}'); author is '${AUTHOR}'."
|
||||||
fi
|
fi
|
||||||
|
warn " via: PR ${OV_SRC}"
|
||||||
warn " when: ${OV_WHEN} (head commit ${HEAD_SHA:0:8}, ${HEAD_TIME})"
|
warn " when: ${OV_WHEN} (head commit ${HEAD_SHA:0:8}, ${HEAD_TIME})"
|
||||||
warn " reason: ${OV_WHY}"
|
warn " reason: ${OV_WHY}"
|
||||||
warn " review waived on: $(printf '%s ' $SENSITIVE)"
|
warn " review waived on: $(printf '%s ' $SENSITIVE)"
|
||||||
ok "sensitive paths cleared by administrator override — this stage does not block."
|
ok "sensitive paths cleared by administrator override — this stage does not block."
|
||||||
SELFNOTE=""; [ "$OV_USER" = "$AUTHOR" ] && SELFNOTE=", **self-waived as author**"
|
SELFNOTE=""; [ "$OV_USER" = "$AUTHOR" ] && SELFNOTE=", **self-waived as author**"
|
||||||
summary "- approval-policy: **OVERRIDDEN by \`${OV_USER}\`** (permission \`${OV_PERM}\`${SELFNOTE})"
|
summary "- approval-policy: **OVERRIDDEN by \`${OV_USER}\`** (permission \`${OV_PERM}\`, via PR ${OV_SRC}${SELFNOTE})"
|
||||||
summary " - reason: ${OV_WHY}"
|
summary " - reason: ${OV_WHY}"
|
||||||
summary " - review waived on: $(printf '\`%s\` ' $SENSITIVE)"
|
summary " - review waived on: $(printf '\`%s\` ' $SENSITIVE)"
|
||||||
exit 0
|
exit 0
|
||||||
@@ -239,10 +263,12 @@ err " honestly approve for itself. Get a human review, or move the change out o
|
|||||||
printf ' %s\n' $SENSITIVE >&2
|
printf ' %s\n' $SENSITIVE >&2
|
||||||
err " The list lives in infra/gitea/approval-policy.json, with a reason per entry."
|
err " The list lives in infra/gitea/approval-policy.json, with a reason per entry."
|
||||||
if [ "$OVERRIDE_ENABLED" = "true" ]; then
|
if [ "$OVERRIDE_ENABLED" = "true" ]; then
|
||||||
err " Or, if you hold admin/owner on this repository, comment on the PR:"
|
err " Or, if you hold admin/owner on this repository, post this on the PR:"
|
||||||
err " ${DIRECTIVE} <reason>"
|
err " ${DIRECTIVE} <reason>"
|
||||||
err " The reason is mandatory, the directive must START a line, and the comment must be"
|
err " as a REVIEW of type 'Comment' (not the plain comment box — CI's token cannot read"
|
||||||
err " NEWER than head commit ${HEAD_SHA:0:8} — push again and you must override again."
|
err " /issues/${PR}/comments without the issue scope; see infra/gitea/set-ci-token.sh)."
|
||||||
|
err " The reason is mandatory, the directive must START a line, and it must be NEWER than"
|
||||||
|
err " head commit ${HEAD_SHA:0:8} — push again and you must override again."
|
||||||
fi
|
fi
|
||||||
summary "- approval-policy: **BLOCKED — needs a non-author approval, or an admin \`${DIRECTIVE}\` comment**"
|
summary "- approval-policy: **BLOCKED — needs a non-author approval, or an admin \`${DIRECTIVE}\` comment**"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -26,7 +26,13 @@ BEFORE="2026-10-01T11:30:00Z"
|
|||||||
|
|
||||||
# run <comments-json> -> stdout of the parser (stderr is diagnostic only)
|
# run <comments-json> -> stdout of the parser (stderr is diagnostic only)
|
||||||
run() {
|
run() {
|
||||||
COMMENTS_JSON="$1" HEAD_TIME="${2:-$HEAD_TIME}" DIRECTIVE="${3:-/approve-override}" \
|
COMMENTS_JSON="$1" REVIEWS_JSON="" HEAD_TIME="${2:-$HEAD_TIME}" DIRECTIVE="${3:-/approve-override}" \
|
||||||
|
python3 "$SUT" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# run_rev <reviews-json> — the same, but through the REVIEWS source.
|
||||||
|
run_rev() {
|
||||||
|
COMMENTS_JSON="" REVIEWS_JSON="$1" HEAD_TIME="${2:-$HEAD_TIME}" DIRECTIVE="${3:-/approve-override}" \
|
||||||
python3 "$SUT" 2>/dev/null
|
python3 "$SUT" 2>/dev/null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,10 +60,24 @@ expect_user() {
|
|||||||
expect_reason() {
|
expect_reason() {
|
||||||
local label="$1" json="$2" want="$3" out got
|
local label="$1" json="$2" want="$3" out got
|
||||||
out="$(run "$json")"
|
out="$(run "$json")"
|
||||||
got="$(cut -f3- <<<"$out")"
|
got="$(cut -f4- <<<"$out")"
|
||||||
if [ "$got" = "$want" ]; then ok "$label"; else bad "$label (wanted '${want}', got '${got}')"; fi
|
if [ "$got" = "$want" ]; then ok "$label"; else bad "$label (wanted '${want}', got '${got}')"; fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# check <label> <actual> <wanted>
|
||||||
|
check() {
|
||||||
|
if [ "$2" = "$3" ]; then ok "$1"; else bad "$1 (wanted '${3}', got '${2}')"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# review <login> <submitted_at> <state> <body>
|
||||||
|
review() {
|
||||||
|
python3 -c '
|
||||||
|
import json, sys
|
||||||
|
print(json.dumps([{"user": {"login": sys.argv[1]}, "submitted_at": sys.argv[2],
|
||||||
|
"state": sys.argv[3], "body": sys.argv[4]}]))
|
||||||
|
' "$1" "$2" "$3" "$4"
|
||||||
|
}
|
||||||
|
|
||||||
echo "== ci/approval-override.py"
|
echo "== ci/approval-override.py"
|
||||||
|
|
||||||
# --- the happy path -------------------------------------------------------------------
|
# --- the happy path -------------------------------------------------------------------
|
||||||
@@ -141,5 +161,72 @@ else
|
|||||||
bad "with no HEAD_TIME the candidate should still be returned (got '${out}')"
|
bad "with no HEAD_TIME the candidate should still be returned (got '${out}')"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# --- the REVIEWS source ---------------------------------------------------------------
|
||||||
|
# Load-bearing, not redundant. Measured on PR #38: SETTINGS_API_TOKEN is minted
|
||||||
|
# [read:repository, read:admin], /issues/{n}/comments needs the ISSUE scope, so a well-formed
|
||||||
|
# override in the comment box came back as an error object and was invisible. /pulls/{n}/reviews
|
||||||
|
# IS readable with that token, so a review body is the route that works today.
|
||||||
|
check "a review body carries an override" \
|
||||||
|
"$(cut -f1 <<<"$(run_rev "$(review jakman85 "$AFTER" COMMENT '/approve-override via a review')")")" \
|
||||||
|
jakman85
|
||||||
|
check "the source is reported as 'review'" \
|
||||||
|
"$(cut -f3 <<<"$(run_rev "$(review jakman85 "$AFTER" COMMENT '/approve-override via a review')")")" \
|
||||||
|
review
|
||||||
|
check "the source is reported as 'comment' for a comment" \
|
||||||
|
"$(cut -f3 <<<"$(run "$(comment jakman85 "$AFTER" '/approve-override via a comment')")")" \
|
||||||
|
comment
|
||||||
|
|
||||||
|
out="$(run_rev "$(review jakman85 "$BEFORE" COMMENT '/approve-override stale review')")"
|
||||||
|
check "a stale review override is dropped too" "${out:-<none>}" "<none>"
|
||||||
|
|
||||||
|
out="$(run_rev "$(review jakman85 "$AFTER" PENDING '/approve-override unsubmitted draft')")"
|
||||||
|
check "a PENDING (unsubmitted) review is NOT an act of anything" "${out:-<none>}" "<none>"
|
||||||
|
|
||||||
|
check "an APPROVED review carrying the directive still counts as an override" \
|
||||||
|
"$(cut -f1 <<<"$(run_rev "$(review jakman85 "$AFTER" APPROVED '/approve-override belt and braces')")")" \
|
||||||
|
jakman85
|
||||||
|
|
||||||
|
out="$(run_rev "$(review jakman85 "$AFTER" COMMENT 'ordinary review prose, no directive')")"
|
||||||
|
check "an ordinary review body is not an override" "${out:-<none>}" "<none>"
|
||||||
|
|
||||||
|
# --- an unreadable source degrades to 'no candidates', never to a pass ----------------
|
||||||
|
# The real-world shape: Gitea answers with {"message": "token does not have ... scope"}.
|
||||||
|
ERR_OBJ='{"message":"token does not have at least one of required scope(s): [read:issue]"}'
|
||||||
|
out="$(COMMENTS_JSON="$ERR_OBJ" REVIEWS_JSON="" HEAD_TIME="$HEAD_TIME" \
|
||||||
|
DIRECTIVE=/approve-override python3 "$SUT" 2>/dev/null)"
|
||||||
|
check "an error OBJECT from the comments API yields no override" "${out:-<none>}" "<none>"
|
||||||
|
|
||||||
|
out="$(COMMENTS_JSON="$ERR_OBJ" \
|
||||||
|
REVIEWS_JSON="$(review jakman85 "$AFTER" COMMENT '/approve-override reviews still work')" \
|
||||||
|
HEAD_TIME="$HEAD_TIME" DIRECTIVE=/approve-override python3 "$SUT" 2>/dev/null)"
|
||||||
|
check "an unreadable COMMENTS source does not suppress a valid REVIEW override" \
|
||||||
|
"$(cut -f1 <<<"$out")" jakman85
|
||||||
|
|
||||||
|
# and it must SAY so, or a scope problem reads as 'you never posted one' (it did, on PR #38)
|
||||||
|
errout="$(COMMENTS_JSON="$ERR_OBJ" REVIEWS_JSON="" HEAD_TIME="$HEAD_TIME" \
|
||||||
|
DIRECTIVE=/approve-override python3 "$SUT" 2>&1 >/dev/null)"
|
||||||
|
case "$errout" in
|
||||||
|
*"not a list"*read:issue*) ok "the diagnostic names the scope Gitea complained about" ;;
|
||||||
|
*) bad "the diagnostic should quote Gitea's message (got: ${errout})" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# --- latest wins ACROSS the two sources ----------------------------------------------
|
||||||
|
out="$(COMMENTS_JSON="$(comment early "2026-10-01T12:10:00Z" '/approve-override from a comment')" \
|
||||||
|
REVIEWS_JSON="$(review later "2026-10-01T12:50:00Z" COMMENT '/approve-override from a review')" \
|
||||||
|
HEAD_TIME="$HEAD_TIME" DIRECTIVE=/approve-override python3 "$SUT" 2>/dev/null)"
|
||||||
|
check "the latest override wins across both sources (review newer)" "$(cut -f1 <<<"$out")" later
|
||||||
|
check "and its source is labelled" "$(cut -f3 <<<"$out")" review
|
||||||
|
|
||||||
|
out="$(COMMENTS_JSON="$(comment later "2026-10-01T12:50:00Z" '/approve-override from a comment')" \
|
||||||
|
REVIEWS_JSON="$(review early "2026-10-01T12:10:00Z" COMMENT '/approve-override from a review')" \
|
||||||
|
HEAD_TIME="$HEAD_TIME" DIRECTIVE=/approve-override python3 "$SUT" 2>/dev/null)"
|
||||||
|
check "the latest override wins across both sources (comment newer)" "$(cut -f1 <<<"$out")" later
|
||||||
|
check "and that source is labelled too" "$(cut -f3 <<<"$out")" comment
|
||||||
|
|
||||||
|
# --- both sources absent entirely -----------------------------------------------------
|
||||||
|
out="$(COMMENTS_JSON="" REVIEWS_JSON="" HEAD_TIME="$HEAD_TIME" \
|
||||||
|
DIRECTIVE=/approve-override python3 "$SUT" 2>/dev/null)"
|
||||||
|
check "no sources at all yields no override, and no crash" "${out:-<none>}" "<none>"
|
||||||
|
|
||||||
printf '\n %d passed, %d failed\n' "$PASS" "$FAIL"
|
printf '\n %d passed, %d failed\n' "$PASS" "$FAIL"
|
||||||
[ "$FAIL" -eq 0 ] || exit 1
|
[ "$FAIL" -eq 0 ] || exit 1
|
||||||
@@ -87,6 +87,43 @@ Also updated: `CLAUDE.md` §1 and the §2 enforcement table (the "approval requi
|
|||||||
explicitly ⬜ with the reason, rather than a stale ✅), and `infra/gitea/README.md` gains §6 and
|
explicitly ⬜ with the reason, rather than a stale ✅), and `infra/gitea/README.md` gains §6 and
|
||||||
marks its historical step 4 as reversed — do not re-apply.
|
marks its historical step 4 as reversed — do not re-apply.
|
||||||
|
|
||||||
|
**Second pass, 2026-10-02 — the override did not work the first time, and why.** The operator ran
|
||||||
|
the live PATCH (`required_approvals: 0` confirmed live, with `block_admin_merge_override` and
|
||||||
|
`enable_status_check` still `true`), merged PR #35, and posted `/approve-override Need to ensure
|
||||||
|
approval override work as expected` on PR #38. The gate re-ran (run 203) and **still blocked.**
|
||||||
|
|
||||||
|
The comment was not at fault: posted by `jakman85`, directive at the start of the line, reason
|
||||||
|
present, and 29 minutes **newer** than head commit `616a497`, so it cleared the staleness rule.
|
||||||
|
What failed was that **CI could not read it.** Run 203's log:
|
||||||
|
|
||||||
|
approval-override: COMMENTS_JSON is not a list
|
||||||
|
|
||||||
|
`SETTINGS_API_TOKEN` is minted `["read:repository", "read:admin"]` by
|
||||||
|
`infra/gitea/set-ci-token.sh`, and Gitea puts `/issues/{n}/comments` behind the **issue** scope. So
|
||||||
|
Gitea returned an error *object*, `jq -e .` accepted it as valid JSON, and the stage fell through
|
||||||
|
to "no approving review" — **a permission failure wearing the costume of a missing override.**
|
||||||
|
`/pulls/{n}/files` and `/pulls/{n}/reviews` are under `read:repository`, which is exactly why every
|
||||||
|
other part of the same stage worked and this one did not.
|
||||||
|
|
||||||
|
Three fixes, and the first is the one that mattered most:
|
||||||
|
|
||||||
|
1. **The diagnosis is no longer misleading.** `COMMENTS_JSON` is out of the must-parse-or-block
|
||||||
|
loop and is explicitly checked for being an *array*; when it is not, the stage prints Gitea's
|
||||||
|
own `.message`, names the missing scope, and says where to post the override instead. An
|
||||||
|
unreadable source can only cause a false BLOCK, never a false pass, so degrading it to a loud
|
||||||
|
warning is safe — and blocking on it was never the honest behaviour.
|
||||||
|
2. **The override is accepted from a PR REVIEW body as well as a comment**, so the hatch is not
|
||||||
|
hostage to one API scope. A review of type "Comment" works on the token CI has *today*. The
|
||||||
|
gate names which source it used. `PENDING` (unsubmitted, author-only draft) reviews are dropped.
|
||||||
|
3. **`set-ci-token.sh` now requests `read:issue`**, which makes the ordinary comment box work —
|
||||||
|
the thing anybody reaches for first. It takes effect only on a **re-run**: an existing token's
|
||||||
|
scopes cannot be widened in place.
|
||||||
|
|
||||||
|
`ci/tests/approval-override.test.sh` grows from 23 to **38 assertions**, covering the reviews
|
||||||
|
source, `PENDING` reviews, an error *object* from the comments API, "an unreadable comments source
|
||||||
|
must not suppress a valid review override", latest-wins across both sources, and that the
|
||||||
|
diagnostic actually quotes Gitea's message.
|
||||||
|
|
||||||
**One adjacent fix, deliberately in scope: `ci/tests/gate-decision.test.sh` was already broken.**
|
**One adjacent fix, deliberately in scope: `ci/tests/gate-decision.test.sh` was already broken.**
|
||||||
It hand-maintains a list of stub stage scripts that "must name EVERY stage `gate.sh` runs", and it
|
It hand-maintains a list of stub stage scripts that "must name EVERY stage `gate.sh` runs", and it
|
||||||
did not name `approval-policy` — added to `gate.sh` by PR #17 and never added here. `run_stage`
|
did not name `approval-policy` — added to `gate.sh` by PR #17 and never added here. `run_stage`
|
||||||
@@ -130,8 +167,30 @@ missing from its test. `make -C ci test` goes from **FAILED to all suites passed
|
|||||||
**FAIL, 15 failed / 35 passed**. After deriving the stub list from `gate.sh`: **PASS, 50
|
**FAIL, 15 failed / 35 passed**. After deriving the stub list from `gate.sh`: **PASS, 50
|
||||||
assertions**.
|
assertions**.
|
||||||
- **`make -C ci test` in the pinned toolbox: `ci/tests: all suites passed`, rc 0** — approval-override
|
- **`make -C ci test` in the pinned toolbox: `ci/tests: all suites passed`, rc 0** — approval-override
|
||||||
23, gate-decision 50, touched-requirements-base 31, test-attribution 15, touched-requirements 26,
|
**38**, gate-decision 50, touched-requirements-base 31, test-attribution 15, touched-requirements 26,
|
||||||
decisions-log pass. It was rc 2 (`ci/tests: FAILED`) before the stub-list fix.
|
decisions-log pass. It was rc 2 (`ci/tests: FAILED`) before the stub-list fix.
|
||||||
|
- **The live flip worked, and the drift detector proved it.** Run 202 (before): `gitea-settings:
|
||||||
|
FAIL`, `required_approvals: DECLARED 0 but LIVE is 1`. Run 203 (after the operator ran the
|
||||||
|
PATCH): `gitea-settings: pass`, with `block_admin_merge_override = true` among the keys it
|
||||||
|
checked. So the narrow change held — the approval count moved and the gate's teeth did not.
|
||||||
|
- **The first override attempt failed for a measured reason, not a guessed one.** Run 203 log:
|
||||||
|
`approval-override: COMMENTS_JSON is not a list`. Token scopes read from `set-ci-token.sh`:
|
||||||
|
`[read:repository, read:admin]`. The same run successfully read `/pulls/38` and
|
||||||
|
`/pulls/38/files` (author and sensitive paths both resolved) and got past the reviews
|
||||||
|
validation, which is how I know `/pulls/{n}/reviews` IS readable with that token — from
|
||||||
|
behaviour, not from documentation.
|
||||||
|
- **The comment itself was valid.** `created_at` 2026-10-02T05:31:24Z vs head commit `616a497`
|
||||||
|
committer date 2026-10-02T00:02:20-05:00 → the comment is 29m04s newer, so it passed the
|
||||||
|
staleness rule. Ruling the comment out is what left the token scope as the only candidate.
|
||||||
|
- **`?limit=1000` is not the culprit.** Queried `/issues/38/comments` with `limit=1000`, `limit=50`
|
||||||
|
and no limit using my own credentials: `type=array` in all three. So the difference is the
|
||||||
|
credential, not the query.
|
||||||
|
- **The root cause is confirmed by Gitea, in its own words.** Run 206 (the fix in place):
|
||||||
|
`[warn] cannot read PR COMMENTS from Gitea — token does not have at least one of required
|
||||||
|
scope(s), required=[read:issue], token scope=read:admin,read:repository.` followed by the
|
||||||
|
instruction to post the override as a review. That is the diagnosis the first attempt should
|
||||||
|
have produced, and `gitea-settings: pass` in the same run. The stage still ends `FAIL`, correctly
|
||||||
|
— there is no valid override on the PR yet.
|
||||||
- **Toolbox merge gate** (`./infra/devcontainer/dev.sh run bash -c './ci/gate.sh'`), run on the
|
- **Toolbox merge gate** (`./infra/devcontainer/dev.sh run bash -c './ci/gate.sh'`), run on the
|
||||||
rebased branch: **rc 3, `MERGE GATE: UNDETERMINED`**. Green stages: spec-immutability,
|
rebased branch: **rc 3, `MERGE GATE: UNDETERMINED`**. Green stages: spec-immutability,
|
||||||
decisions-log, lint, l0 (23 offline cases, 0 failures), render-golden including the new
|
decisions-log, lint, l0 (23 offline cases, 0 failures), render-golden including the new
|
||||||
@@ -142,20 +201,32 @@ missing from its test. `make -C ci test` goes from **FAILED to all suites passed
|
|||||||
|
|
||||||
## What I did NOT verify
|
## What I did NOT verify
|
||||||
|
|
||||||
- **The live Gitea rule still says `required_approvals: 1`. I did not change it.** Mutating live
|
- **The override has still never been ACCEPTED by a real run.** This is the second attempt at
|
||||||
platform settings is an operator action; `infra/gitea/allow-admin-self-merge.sh` is written,
|
this bullet, which is itself the point: the first pass called the hatch unverified, and the
|
||||||
syntax-checked, and **unrun**. The declaration and the live rule must move together.
|
first real test found a defect that no amount of local testing would have (an API scope CI has
|
||||||
*Now measured, not predicted:* PR #38's CI run (run 202, job 0) fails at **1m44s** with
|
and I did not). What is now measured is that the parse → *reject* path works end to end and
|
||||||
`required_approvals: DECLARED 0 but LIVE is 1` → `gitea-settings: FAIL`. Correct behaviour, and
|
diagnoses itself. The **accepting** branch — directive found → live permission lookup → exit 0 —
|
||||||
the PR stays red until the script is run.
|
has never executed. Posting the override as a **review** on PR #38 is its first real test, and
|
||||||
- **The override has never been ACCEPTED by a real run** — only offered. Measured in the same CI
|
I would not be surprised by a third round.
|
||||||
log: the stage reached `approval-policy: FAIL` on the sensitive paths and printed the *new*
|
- **`read:issue` is the right scope name — Gitea said so itself, so this is no longer a guess.**
|
||||||
code's guidance, `Or, if you hold admin/owner on this repository, comment on the PR:
|
Run 206 printed Gitea's own message: `token does not have at least one of required scope(s),
|
||||||
/approve-override <reason> ... NEWER than head commit 7b9f733c`. So the new stage **is** what
|
required=[read:issue], token scope=read:admin,read:repository`. That both confirms the root
|
||||||
runs on this PR (self-bootstrapping confirmed by measurement, not by reasoning), and the
|
cause and names the string, which matters because this repo has been burned by a token *name*
|
||||||
authorising branch — comment parse → live permission lookup → exit 0 — is still **unexercised**.
|
that could not exist (`GITEA_SETTINGS_TOKEN`). What is **still** unverified is that the token
|
||||||
Posting the comment on PR #38 is its first real test. If it does not work, the recovery is to
|
*mint* accepts it — `POST /users/{u}/tokens` with that scope has not been run. The review route
|
||||||
PATCH `required_approvals` and read run 202's successor log.
|
does not depend on it.
|
||||||
|
- **I did not verify that Gitea lets a PR's own author submit a `COMMENT`-type review.** Gitea
|
||||||
|
blocks author self-*approval*; "Comment" is expected to be allowed, and that expectation is now
|
||||||
|
load-bearing for the recommended route. It is one click to find out, and I deliberately did not
|
||||||
|
test it by posting an override myself: doing so as `gitea-admin` is precisely the agent
|
||||||
|
self-override that `CLAUDE.md` now forbids.
|
||||||
|
- **The whole re-run mechanism is manual.** `gate.yml` triggers on `pull_request` and `push: main`
|
||||||
|
only — there is no `issue_comment` trigger, so posting an override does **not** re-run the gate.
|
||||||
|
Re-run the job from the Actions UI, or close and reopen the PR (which fires `pull_request` and
|
||||||
|
leaves the head SHA alone). **Do not push an empty commit to re-trigger it:** that moves the head
|
||||||
|
commit and makes the override stale by the rule above, so it would have to be posted again. A
|
||||||
|
comment trigger was considered and not built — it would need to check out PR head code while the
|
||||||
|
job holds `SETTINGS_API_TOKEN`, which is the classic pwn-request shape and not worth it here.
|
||||||
- **I have not seen the gate still REFUSE a merge** now that approvals are 0. The claim that
|
- **I have not seen the gate still REFUSE a merge** now that approvals are 0. The claim that
|
||||||
`block_admin_merge_override: true` keeps a red gate unmergeable rests on Gitea's documented
|
`block_admin_merge_override: true` keeps a red gate unmergeable rests on Gitea's documented
|
||||||
behaviour and on the 2026-09-29 session, **not** on a provoked refusal in this one. That probe
|
behaviour and on the 2026-09-29 session, **not** on a provoked refusal in this one. That probe
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
---
|
||||||
|
date: 2026-10-02
|
||||||
|
branch: fix/plex-quicksync-verification
|
||||||
|
title: Hardware transcoding works — and `runbooks/plex-quicksync.md`'s verification section was wrong
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
**Hardware transcoding is confirmed working on prod.** Measured 2026-10-02 from
|
||||||
|
`Plex Media Server.log` in the running pod: `vaapi` on `/dev/dri/renderD128`
|
||||||
|
(`Intel CoffeeLake-S GT2 [UHD Graphics 630]`), `using hardware decode accelerator vaapi`,
|
||||||
|
`zero-copy support present`, `using zero-copy transcoding`, and
|
||||||
|
`final decoder: vaapi, final encoder: vaapi`. VAAPI for **both** directions, zero-copy, so frames
|
||||||
|
never round-trip through the CPU. The device-plugin mechanism from PR #37 works end to end.
|
||||||
|
|
||||||
|
**And `runbooks/plex-quicksync.md`'s verification section was wrong, which is the correction this
|
||||||
|
branch exists to publish.** The page said the Plex dashboard showing `Transcode (hw)` was "the *only*
|
||||||
|
proof available, and nothing else will ever tell you". The operator had hardware transcoding fully
|
||||||
|
working while **Dashboard → Activity showed nothing of the sort** — so the page's authoritative check
|
||||||
|
was silent on a working system, and sent someone looking for a fault that did not exist. Three
|
||||||
|
inference errors are now corrected on the page, two of them recorded as named false leads so the next
|
||||||
|
reader does not re-walk them:
|
||||||
|
|
||||||
|
1. **The dashboard is not proof.** Replaced as the primary check by Plex's own log — the grep is in
|
||||||
|
the page, in its **redacting** form, because `Plex Media Server.log` carries API URLs containing
|
||||||
|
`X-Plex-Token` (CLAUDE.md §6; a credential has been leaked into a transcript here before). The
|
||||||
|
page also now says explicitly that **`Plex Transcoder Statistics.log` is the wrong file** — it
|
||||||
|
holds XML `<Variant .../>` records, not ffmpeg output, and the name invites the mistake — and why
|
||||||
|
`grep -a` is required. The dashboard is **kept but demoted** to a secondary, best-effort signal,
|
||||||
|
with its absence stated to prove nothing.
|
||||||
|
2. **An fd snapshot is not evidence.** A `/proc/*/fd` sweep inside the pod found no holder of
|
||||||
|
`renderD128` while hardware transcoding was active; transcode sessions are short and the scan
|
||||||
|
landed in a gap. Recorded as a documented false lead, with the reliable process-level check in its
|
||||||
|
place: the transcoder's **argv** (`-hwaccel`, `-hwaccel_device`, `vaapi`, `renderD128`), found via
|
||||||
|
`ps -eo pid,user,comm | grep -i transcoder`.
|
||||||
|
3. **A missing preference key does not mean the setting is off.** The page told readers to treat an
|
||||||
|
absent `HardwareAcceleratedCodecs` in `Preferences.xml` as "never enabled". With hardware
|
||||||
|
acceleration demonstrably on and working, that key was **absent from all 27 attributes** while
|
||||||
|
`HardwareDevicePath` **was** written and the log said `hardware transcoding: enabled`. First-command
|
||||||
|
#4 now reads both keys and states plainly that `HardwareDevicePath` present is a positive signal,
|
||||||
|
absence of `HardwareAcceleratedCodecs` means nothing either way, only `="0"` is conclusive, and the
|
||||||
|
log settles it.
|
||||||
|
|
||||||
|
Also on the page:
|
||||||
|
|
||||||
|
- **GID `992` is no longer presented as a number to expect.** Under the plugin the injected device
|
||||||
|
arrives `root:root`, so the image's `cont-init.d` hook resolves GID **`0`** and adds `plex` to the
|
||||||
|
**root group** — measured `Groups: 0 100 1000`, `id plex` → `groups=1000(plex),0(root),100(users)`.
|
||||||
|
992 was the hostPath-era host `render` GID. The page now says so, marks the root-group membership as
|
||||||
|
an inherent property of the plugin's `DeviceSpec` rather than a defect, and repeats that it is
|
||||||
|
**not** a reason to reintroduce `supplementalGroups` (still a no-op: `s6-setuidgid` rebuilds the
|
||||||
|
group list from `/etc/group`). The stale `Groups: 100 1000` claim in the escalation section is gone.
|
||||||
|
- **A new troubleshooting entry for legitimate per-title fallbacks**, marked as a **hypothesis**.
|
||||||
|
Between two fully hardware sessions, one logged `enabled, but no hardware decode accelerator found`
|
||||||
|
and empty final decoder/encoder. UHD 630 decodes H.264, HEVC 8/10-bit and VP9 but **not AV1** and
|
||||||
|
not 10-bit 4:2:2 HEVC, so the likely cause is the source file, not the platform — explicitly
|
||||||
|
written as a lead to check first, **not** a confirmed diagnosis of that item.
|
||||||
|
- The status is stated at the top of the page and in a rewritten provenance footer that separates the
|
||||||
|
2026-10-02 confirmations from the superseded 2026-09-30/2026-10-01 hostPath-era readings.
|
||||||
|
- `runbooks/README.md`'s index entry for the page was stale in the same two ways (it still described a
|
||||||
|
`/dev/dri` hostPath and the dashboard-only proof) and is corrected to match.
|
||||||
|
|
||||||
|
Unchanged on purpose: the "STOP HERE IF PLEX IS `Pending`" section and the escape hatch (delete the
|
||||||
|
`hardware:` block from `catalog/plex.yaml`, re-render) keep exactly their existing prominence — they
|
||||||
|
are correct and they are the 2am material. The "no alert is possible" reasoning is also kept verbatim
|
||||||
|
in substance: Plex declares no `metricsEndpoint`, renders no ServiceMonitor, and a fallback alert
|
||||||
|
would be a rule evaluating against nothing.
|
||||||
|
|
||||||
|
**Documentation only.** No change to `catalog/`, `schemas/`, `apps/` or `platform/`.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
- **Gate, in the pinned toolbox from this worktree** (`DEV_KUBECONFIG=/dev/null`, `ENGINE` shim
|
||||||
|
bind-mounting the main checkout's `.git`): **rc 3 UNDETERMINED**, diff base
|
||||||
|
`origin/main @ 948875aad37d`, with exactly the three expected skips and **no failures**. Green:
|
||||||
|
`decisions-log`, `lint`, `l0`, `render-golden`, `l1`, `approval-policy`. Skipped:
|
||||||
|
`spec-immutability` (spec repo unavailable → R-TEST-06 not verified), `gitea-settings`
|
||||||
|
(`SETTINGS_API_TOKEN` unset), `conformance` (`CLUSTER_AVAILABLE!=true` → L2/L3 not run).
|
||||||
|
Touched requirements: **R-OBS-04**, reported `UNVERIFIED` for lack of a cluster. `approval-policy`
|
||||||
|
passed as `N/A` locally (`event='none'`, not a `pull_request`) — it evaluates for real on the PR.
|
||||||
|
**rc 3 is not green**, it is the calibrated worktree result, and it is reported here as rc 3.
|
||||||
|
- **The gate was run three times, with the same rc 3 / three-skip result on each committed tip** —
|
||||||
|
including the final one. Worth recording from the first attempt: run *before* committing, with
|
||||||
|
`HEAD == origin/main`, the touched-requirement set is `NOT DETERMINABLE (on-base)` and the
|
||||||
|
R-TEST-03 coverage gate cannot be applied at all. Commit before gating from a fresh worktree branch.
|
||||||
|
- **`runbooks/kustomization.yaml` still carries `- plex-quicksync=plex-quicksync.md`** in the
|
||||||
|
`configMapGenerator` `files:` list (line 110) — checked explicitly, because without it the page
|
||||||
|
merges and still 404s. `README=README.md` is likewise present, which is what serves the corrected
|
||||||
|
index entry.
|
||||||
|
- **This change touches no `sensitive_paths`.** `infra/gitea/approval-policy.json` lists `ci/**`,
|
||||||
|
`.gitea/**`, `conformance/**`, `clusters/**`, `bootstrap/**`, `infra/gitea/**`,
|
||||||
|
`platform/kyverno/**`, `platform-config/kustomization.yaml`, `.sops.yaml`, `**/*.sops.yaml`,
|
||||||
|
`**/rbac.yaml`, `CLAUDE.md`. `runbooks/**` and `decisions/**` are not among them, and the
|
||||||
|
`approval-policy` stage passed.
|
||||||
|
- **No YAML was added or edited**, so `indent-sequences: consistent` (which blocked a sibling branch
|
||||||
|
today) is not in play here; `lint` passed over the full tracked YAML set regardless.
|
||||||
|
- **`DECISIONS-LOG.md` is untouched** — this handoff is its own file, per CLAUDE.md §7.
|
||||||
|
- **The PR's own CI run was read, not assumed** (PR #39, run 208, task 261, head `1677dff5688b`).
|
||||||
|
**It is RED, and not because of this branch.** Every stage this change could affect is green —
|
||||||
|
`decisions-log`, `lint`, `l0`, `render-golden`, `l1`, `approval-policy` (`approval-policy` evaluated
|
||||||
|
for real on the `pull_request` event and passed: no sensitive path touched). The single failure is
|
||||||
|
**`gitea-settings`**, quoting the measured field:
|
||||||
|
|
||||||
|
[fail] required_approvals: DECLARED 1 but LIVE is 0
|
||||||
|
[fail] Gitea has DRIFTED from infra/gitea/expected-branch-protection.json.
|
||||||
|
[fail] MERGE GATE: BLOCKED
|
||||||
|
|
||||||
|
Every other declared setting matched (`block_admin_merge_override`, `block_on_rejected_reviews`,
|
||||||
|
`dismiss_stale_approvals`, `enable_status_check`, `enable_push=false`,
|
||||||
|
`status_check_contexts = ["gate / gate (pull_request)"]`, `runners online: 1`). So **live `main`
|
||||||
|
currently requires ZERO approvals**, the declaration still says 1, and with
|
||||||
|
`REQUIRE_GITEA_SETTINGS: "true"` in `.gitea/workflows/gate.yml` that drift **blocks every PR in
|
||||||
|
the repo**, not just this one. Note the shape of it: the check that would notice approvals are
|
||||||
|
unenforced is the check that is failing. Local branch `fix/admin-approval-override` (`616a497`,
|
||||||
|
"fix(ci): let an administrator merge their own green PR") looks like the in-flight work that means
|
||||||
|
to change the declaration. **I did not touch it** — `infra/gitea/**` is a sensitive path, needs a
|
||||||
|
non-author approval, and is outside this branch's documentation-only scope. It is an operator
|
||||||
|
decision: re-assert the protection, or change the declaration deliberately in its own PR.
|
||||||
|
- The repository-wide grep for `992` to find what else still asserts it: remaining occurrences are in
|
||||||
|
`catalog/plex.yaml`, `catalog/render.py`, `schemas/renderer-contract.md`,
|
||||||
|
`platform/intel-gpu-plugin/README.md`, `platform/intel-gpu-plugin/kustomization.yaml`, and three
|
||||||
|
older `decisions/` entries.
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- **I did not re-measure anything on the live cluster in this session.** Every platform number on the
|
||||||
|
page — the VAAPI/zero-copy log lines, `RW-OPEN-OK` as uid 1000, the `gpu.intel.com/i915` limits and
|
||||||
|
requests on the pod spec, `Groups: 0 100 1000`, `id plex`, the 27 `Preferences.xml` attributes, the
|
||||||
|
empty `/proc/*/fd` sweep — came to me as measurements taken earlier on 2026-10-02 and was
|
||||||
|
transcribed, not re-run. Per CLAUDE.md §2b, treat the page as reporting those readings rather than
|
||||||
|
as proof of current state; re-run the primary check before relying on it.
|
||||||
|
- **The per-title fallback cause is unverified and labelled as such.** The source codec of the title
|
||||||
|
that logged `no hardware decode accelerator found` was never read. The AV1 / 10-bit-4:2:2-HEVC
|
||||||
|
explanation is inferred from UHD 630's capability set alone.
|
||||||
|
- **Whether Plex's dashboard *ever* shows `Transcode (hw)` on this deployment is unknown.** It was
|
||||||
|
observed silent during working hardware transcoding; whether that is a client/version quirk, a
|
||||||
|
reporting bug, or something about this setup was not investigated. The page therefore demotes it
|
||||||
|
rather than explaining it.
|
||||||
|
- **The commands as written in the page were not executed from the page.** They are the forms that
|
||||||
|
were run, re-wrapped for `dev.sh`; in particular the `exec -it deploy/plex -- sh` route for the argv
|
||||||
|
check is noted as possibly lacking a TTY under `dev.sh run` and was not exercised in that shape.
|
||||||
|
- **Stale `992` copies outside `runbooks/` were left alone**, deliberately — scope was documentation
|
||||||
|
only, and `catalog/plex.yaml`, `catalog/render.py`, `schemas/renderer-contract.md` and
|
||||||
|
`platform/intel-gpu-plugin/` all still carry the hostPath-era `Groups: 44 100 992 1000` reading as
|
||||||
|
if current. `render.py --selftest` asserts around that comment, so correcting it is a real change
|
||||||
|
with gate consequences, not a doc edit. **That is open work.** Older `decisions/` entries also cite
|
||||||
|
992; those are historical records and should stay as written.
|
||||||
|
- **CI on the PR.** The branch is pushed and the PR opened; I did not watch the Gitea run report, so
|
||||||
|
nothing here should be read as "CI green". The only gate result I can quote is the local rc 3 above.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
date: 2026-10-03
|
||||||
|
branch: fix/cilium-cgroup-hostroot
|
||||||
|
title: Cilium socket-LB never covered host processes — cgroup.hostRoot pointed at an unmounted path
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
`cgroup.hostRoot` changes from `/run/cilium/cgroupv2` to `/sys/fs/cgroup` in all three parity
|
||||||
|
copies: `platform/cilium/helmrelease.yaml` (authoritative), `platform/cilium/values.yaml`
|
||||||
|
(mirror) and `infra/ansible/roles/cilium/templates/cilium-values.yaml.j2` (bootstrap).
|
||||||
|
`autoMount` stays `false`.
|
||||||
|
|
||||||
|
**The defect.** With `autoMount: false`, the Cilium chart only bind-mounts whatever exists at
|
||||||
|
`hostRoot`, and expects the operator to have mounted cgroup2 there (cilium 1.20.1 values.yaml).
|
||||||
|
The repo's rationale was "k3s provides the cgroupv2 mount". That is true only for
|
||||||
|
`/sys/fs/cgroup`. Nothing mounts cgroup2 at `/run/cilium/cgroupv2` on these nodes; it is an
|
||||||
|
empty `DirectoryOrCreate` hostPath. So the agent mounted cgroup2 there itself, from inside its
|
||||||
|
own private cgroup namespace, and that mount's root is the agent container's own leaf cgroup.
|
||||||
|
The socket-LB programs (`cil_sock4_connect` etc.) were attached to it and covered only the
|
||||||
|
agent.
|
||||||
|
|
||||||
|
**The consequence.** Every other host-netns process got no Service/LB translation:
|
||||||
|
- Service ClusterIPs were unreachable from every node's host.
|
||||||
|
- The node holding the Gateway's L2 lease could not reach the Gateway VIP (`no route to host`).
|
||||||
|
Other nodes reached it only because their packets crossed the wire to the lease holder, where
|
||||||
|
the tc datapath handles them.
|
||||||
|
- k3s-prod-1 holds both the Gateway lease and the kube-vip API VIP. Its apiserver could not reach
|
||||||
|
the OIDC issuer, logging `oidc: authenticator not initialized` since at least 2026-09-12
|
||||||
|
(899 occurrences by 2026-10-03). kubectl SSO through the VIP therefore failed, and in-cluster
|
||||||
|
OIDC clients failed on whichever calls landed on that apiserver.
|
||||||
|
- Webhooks and aggregated APIs were unaffected only because k3s runs
|
||||||
|
`--enable-aggregator-routing=true` (endpoint IPs, not ClusterIPs).
|
||||||
|
|
||||||
|
Cilium's own source (v1.20.1 `bpf/bpf_sock.c`, `__sock4_xlate_fwd`) handles exactly this case
|
||||||
|
when the hook does run: a host-netns connect to an L7-LB Service (the Gateway) is rewritten to
|
||||||
|
`127.0.0.1:<l7_lb_proxy_port>`, the local Envoy listener. The fix is to make the hook cover the
|
||||||
|
host.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
- `cilium-dbg status --verbose` on k3s-prod-1: `Socket LB: Enabled`, `Coverage: Hostns-only`.
|
||||||
|
The live cilium-config shows `bpf-lb-sock=false`, but socket LB is implied by kube-proxy
|
||||||
|
replacement. The Gateway Service frontends `192.168.2.20:{80,443}` and
|
||||||
|
`10.43.225.79:{80,443}` map to `127.0.0.1:14337`.
|
||||||
|
- Host: `findmnt /run/cilium/cgroupv2` is empty on all three nodes, and `/sys/fs/cgroup` is
|
||||||
|
`cgroup2`.
|
||||||
|
- In the agent: `/run/cilium/cgroupv2` is a `cgroup2 none` mount, the agent's own cgroup reads
|
||||||
|
`0::/`, the mount has 0 child cgroups, and `bpftool cgroup show /run/cilium/cgroupv2` lists all
|
||||||
|
`cil_sock*` programs. k3s runs in `0::/system.slice/k3s.service`, and the agent container in
|
||||||
|
`/kubepods.slice/…/cri-containerd-3ad04cde….scope`.
|
||||||
|
- **The decisive test**, run on k3s-prod-1 in the same netns: raw TCP connect to
|
||||||
|
`192.168.2.20:443`, `10.43.225.79:443` and `10.43.59.141:8080` CONNECTED from inside the agent
|
||||||
|
container (covered cgroup) and FAILED from the host (`No route to host` / timeout).
|
||||||
|
- Blast radius: host-network pods are cilium, cilium-envoy, cilium-operator (API via
|
||||||
|
`192.168.2.9:6443`, not a Service), kube-vip (`127.0.0.1:6443`, `svc_enable=false`) and
|
||||||
|
node-exporter (no outbound). Nothing depends on host Service translation being absent; the
|
||||||
|
change only makes Service/LB addresses reachable from the host where they currently fail.
|
||||||
|
- `helm template cilium/cilium --version 1.20.1` with the new values, in the toolbox: the
|
||||||
|
DaemonSet bind-mounts hostPath `/sys/fs/cgroup` into `cilium-agent` and `clean-cilium-state`,
|
||||||
|
the cilium-config shows `cgroup-root: "/sys/fs/cgroup"`, and the init containers are unchanged
|
||||||
|
(no `mount-cgroup`).
|
||||||
|
- Toolbox gate: see the PR description.
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- **The rollout itself.** After merge, the agent DaemonSet rolls one node at a time
|
||||||
|
(`maxUnavailable: 1`). On each node, afterwards: host TCP connects to the three targets above
|
||||||
|
must succeed. On k3s-prod-1, the apiserver's background OIDC init retry must stop logging
|
||||||
|
`authenticator not initialized` with no k3s restart.
|
||||||
|
- That the old programs, attached to the previous agent container's cgroup, disappear with it.
|
||||||
|
Expected, because cgroup BPF attachments die with the cgroup, but not observed.
|
||||||
|
- An end-to-end kubectl OIDC login (needs a browser).
|
||||||
|
- No regression test was added. A natural one is an L2 conformance check that a host-netns
|
||||||
|
connect to the Gateway VIP succeeds on the lease holder, but `conformance/**` is a sensitive
|
||||||
|
path. Left as a follow-up.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
date: 2026-10-03
|
||||||
|
branch: fix/node-keycloak-hosts-pin
|
||||||
|
title: Pin the OIDC issuer to the Gateway VIP on prod nodes — the WAN answer does not hairpin
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
Follow-up to PR #40 (`decisions/2026-10-03-public-dns-wan-target.md`). Public DNS for
|
||||||
|
`*.home.keithjackson.dev` now CNAMEs to the WAN IP. The prod nodes resolve through public DNS,
|
||||||
|
and the router does not hairpin, so the kube-apiserver could no longer reach its OIDC issuer
|
||||||
|
`keycloak.home.keithjackson.dev`.
|
||||||
|
|
||||||
|
- `roles/node_baseline`: a new `node_baseline_hosts_pins` list (default `[]`) and one
|
||||||
|
`blockinfile` task (tags `dns`, `hosts`). It writes the pins into `/etc/hosts` as a single
|
||||||
|
marked block. An empty list removes the block.
|
||||||
|
- `group_vars/k3s_prod/vars.yml`: pins `keycloak.{{ k3s_base_domain }}` → `192.168.2.20`, which
|
||||||
|
must stay equal to `GATEWAY_VIP` (parity comment added). It also replaces the stale "no
|
||||||
|
/etc/hosts pin is needed" paragraph with the measured reason the pin is now required.
|
||||||
|
|
||||||
|
Dev is unaffected (empty default).
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
- After PR #40 reconciled (`platform` lastAppliedRevision `576e1ab`, Healthy=True), on
|
||||||
|
k3s-prod-2 and k3s-prod-3: `getent hosts keycloak.home.keithjackson.dev` →
|
||||||
|
`136.51.115.252 wan.home.keithjackson.dev`. `curl https://keycloak…/realms/homelab/.well-known/openid-configuration`
|
||||||
|
→ exit 60, `ssl_verify_result=18` (the router's self-signed cert, not the Gateway).
|
||||||
|
- `journalctl -u k3s` (last 20 min) showed no OIDC/JWKS errors yet. The apiserver still holds
|
||||||
|
the keys it cached before the flip, so the failure is latent until a k3s restart or key rotation.
|
||||||
|
- Neither node has cloud-init (`/etc/cloud` absent), so nothing rewrites `/etc/hosts` at boot.
|
||||||
|
- `--check --diff --tags hosts --limit k3s-prod-2,k3s-prod-3`: each node gets exactly the
|
||||||
|
three-line marked block `192.168.2.20 keycloak.home.keithjackson.dev`.
|
||||||
|
- Outside-in, PR #40 works: check-host.net probes from 3 external nodes got `200 OK` from
|
||||||
|
`https://plex.home.keithjackson.dev/identity` at `136.51.115.252`.
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- **k3s-prod-1**: Ansible cannot reach it. SSH host key verification fails (`known_hosts:94`,
|
||||||
|
ED25519 `SHA256:zgUS8OeWznzCX9hlN7XdZ8iLNkU1G2loXjtJFfEUYpE`), which is consistent with
|
||||||
|
the 2026-09-11 SSD replacement and reinstall. The operator must confirm the fingerprint
|
||||||
|
before the pin can be applied there.
|
||||||
|
- The playbook has not been applied for real. It is an operator run after merge:
|
||||||
|
`ansible-playbook playbooks/node-baseline.yml -e k3s_hosts=k3s_prod --tags hosts`.
|
||||||
|
- An end-to-end kubectl OIDC login after the pin (needs a browser).
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
---
|
||||||
|
date: 2026-10-03
|
||||||
|
branch: feat/public-dns-wan-target
|
||||||
|
title: Public DNS now points at the WAN — Gateway-published names CNAME to an in-cluster DDNS record
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
The operator closed the open "public-target gap" recorded in
|
||||||
|
`platform/externaldns/helmrelease.yaml`. The WAN NAT / port-forwarding (80/443 → Gateway VIP
|
||||||
|
192.168.2.20) was done by the operator outside this repo.
|
||||||
|
|
||||||
|
Operator decisions (2026-10-03):
|
||||||
|
|
||||||
|
1. **Scope: every Gateway-published name goes public.** That covers the five `exposure: public`
|
||||||
|
apps (plex, gitea, keycloak, nextcloud, seerr) and also the hand-authored admin UIs (auth,
|
||||||
|
crafty, grafana, headlamp, the `home.keithjackson.dev` apex). `internal` services still publish
|
||||||
|
nothing: their `controller: none` route annotation is checked before the target is read.
|
||||||
|
2. **The WAN IP is kept current in-cluster**, not hardcoded.
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
|
||||||
|
- `platform/gateway/gateway.yaml`: `external-dns.alpha.kubernetes.io/target: wan.${BASE_DOMAIN}` on
|
||||||
|
the shared Gateway. Every gateway-sourced record changes from `A 192.168.2.20` to
|
||||||
|
`CNAME wan.home.keithjackson.dev`.
|
||||||
|
- `platform/externaldns/ddns.yaml` (new): `favonia/cloudflare-ddns:1.17.1` Deployment in the
|
||||||
|
`external-dns` namespace. It reuses `cloudflare-api-token` and keeps `wan.${BASE_DOMAIN}` an
|
||||||
|
unproxied IPv4 A record at the WAN IP (detected via Cloudflare trace, checked every 5 min,
|
||||||
|
`DELETE_ON_STOP=false`). external-dns has no TXT registry entry for it, so it never owns or
|
||||||
|
deletes it.
|
||||||
|
- The helmrelease comment block and the README now describe the decision instead of the gap.
|
||||||
|
|
||||||
|
Known consequence: **k3s nodes resolve through public DNS** (`node_baseline_dns_servers` = 8.8.8.8 /
|
||||||
|
1.1.1.1). The kube-apiserver's OIDC issuer `keycloak.home.keithjackson.dev` therefore resolves to the
|
||||||
|
WAN IP from the nodes, and kubectl SSO works only if the router hairpins WAN:443 back to the Gateway.
|
||||||
|
A LAN-side probe of `136.51.115.252:443` on 2026-10-03 returned a **self-signed placeholder cert**,
|
||||||
|
not the Gateway's Let's Encrypt wildcard. That suggests no hairpin. The cert-based admin kubeconfig
|
||||||
|
is unaffected.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
- Live prod, 2026-10-03, before the change: Gateway `gateway/shared` ADDRESS `192.168.2.20`.
|
||||||
|
External-dns args include `--source=gateway-httproute --source=service --policy=sync
|
||||||
|
--txt-owner-id=prod --domain-filter=keithjackson.dev`, and it logs "All records are already up to
|
||||||
|
date".
|
||||||
|
- Public answers at 1.1.1.1: plex/gitea/keycloak/grafana/auth/apex → `192.168.2.20`;
|
||||||
|
`crafty-mc` → `.22` and `unifi` → `.21` (service source, not affected by this change); jackett →
|
||||||
|
none (internal, correctly unpublished).
|
||||||
|
- The authority (betty.ns.cloudflare.com) has no unprefixed TXT, AAAA or MX at `plex.` or the apex,
|
||||||
|
so the A→CNAME switch has no co-existing record to collide with.
|
||||||
|
- WAN IP from the workstation: `136.51.115.252`.
|
||||||
|
- No CiliumNetworkPolicy or NetworkPolicy in `external-dns`. The Kyverno label and default-deny
|
||||||
|
policies match only `app-namespace` namespaces. The vpn egress policy selects only
|
||||||
|
`egress-class: vpn` pods.
|
||||||
|
- Toolbox gate result: see the PR description (run after this commit).
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- **The outside-in path.** I have no vantage point outside the LAN. Whether
|
||||||
|
`https://<svc>.home.keithjackson.dev` from the internet reaches the Gateway with the LE cert is
|
||||||
|
UNVERIFIED.
|
||||||
|
- **Hairpin / kubectl SSO after the flip.** Likely broken per the probe above. Fix candidates: a
|
||||||
|
node `/etc/hosts` pin for `keycloak.${BASE_DOMAIN}` → Gateway VIP via `node_baseline`, or router
|
||||||
|
NAT reflection.
|
||||||
|
- **The record-type flip itself**, until Flux reconciles: whether Cloudflare accepts external-dns's
|
||||||
|
create-CNAME in the same batch as the delete-A, or converges one interval later.
|
||||||
|
- `favonia/cloudflare-ddns` under this pod securityContext. The README warns that
|
||||||
|
`no-new-privileges` misbehaves on some Proxmox-style hosts. These are bare-metal nodes, but it is
|
||||||
|
untested until the pod runs.
|
||||||
|
- Whether the L4 service-source names (crafty-mc, unifi) should also go public. Not decided, not
|
||||||
|
changed.
|
||||||
|
- `conformance/tests/l0-static/internal-services-publish-no-public-dns/check.sh` header lines 39–40
|
||||||
|
still describe the target as open. That is a sensitive path, so it is left for a follow-up.
|
||||||
@@ -306,19 +306,27 @@ node_baseline_dns_servers:
|
|||||||
# k3s_base_domain, below). It reaches the issuer through the NODE resolver, i.e. through
|
# k3s_base_domain, below). It reaches the issuer through the NODE resolver, i.e. through
|
||||||
# these PUBLIC upstreams — not through CoreDNS's split-horizon answer.
|
# these PUBLIC upstreams — not through CoreDNS's split-horizon answer.
|
||||||
#
|
#
|
||||||
# THIS USED TO BE BROKEN, AND THE 2026-09-04 SUBDOMAIN SPLIT FIXED IT. While prod was
|
# THE PUBLIC ANSWER IS NOW THE WAN, SO THE ISSUER IS PINNED (2026-10-03). Since
|
||||||
# authored to serve dev's hostnames, a prod node resolving keycloak.home.keithjackson.dev
|
# platform/gateway/gateway.yaml set an external-dns `target`, every public name is a CNAME
|
||||||
# publicly got DEV's Gateway VIP (192.168.1.53) — the wrong cluster, on the wrong LAN, so
|
# to wan.home.keithjackson.dev = the home WAN IP. The SOHO router does NOT hairpin: from the
|
||||||
# kubectl OIDC SSO on prod could not work at all. Now prod's issuer is
|
# LAN, WAN:443 answers with the router's own self-signed placeholder cert (measured from
|
||||||
# keycloak.prod.home.keithjackson.dev, and the public record for that name is published by
|
# k3s-prod-2/-3 2026-10-03: `curl` exit 60, ssl_verify_result=18). The apiserver would then
|
||||||
# PROD's own external-dns pointing at PROD's Gateway VIP (192.168.2.20). The public answer
|
# be unable to fetch its issuer's discovery/JWKS, so kubectl OIDC SSO would fail on the
|
||||||
# and the split-horizon answer therefore AGREE, and no /etc/hosts pin is needed.
|
# next k3s restart or Keycloak key rotation. node_baseline_hosts_pins (below) pins the
|
||||||
|
# issuer host to the Gateway VIP in /etc/hosts, which glibc consults before these
|
||||||
|
# resolvers. (History: before the 2026-09-04 subdomain split the public answer was DEV's
|
||||||
|
# VIP; after it, the public answer was prod's own VIP and no pin was needed.)
|
||||||
#
|
#
|
||||||
# STILL TO VERIFY on prod bring-up (PROD-PROVISIONING.md §10): this holds only once prod's
|
# The cert-based admin kubeconfig never depends on any of this. It is the break-glass path.
|
||||||
# external-dns has actually published the record. Until then the issuer does not resolve
|
|
||||||
# from the nodes and kubectl SSO fails — the cert-based admin kubeconfig is the break-glass
|
|
||||||
# path, and it always works. Ordering, not a design gap.
|
|
||||||
node_baseline_dns_search: []
|
node_baseline_dns_search: []
|
||||||
|
# /etc/hosts pins: node-process names that must reach the in-cluster Gateway directly rather
|
||||||
|
# than via the public (WAN, non-hairpinned) answer. See "THE PUBLIC ANSWER IS NOW THE WAN" above.
|
||||||
|
# [parity] the IP MUST equal GATEWAY_VIP in clusters/prod/kustomization.yaml (192.168.2.20),
|
||||||
|
# and the name MUST follow k3s_oidc_issuer_url's host (roles/k3s_server/defaults/main.yml).
|
||||||
|
node_baseline_hosts_pins:
|
||||||
|
- ip: "192.168.2.20"
|
||||||
|
names:
|
||||||
|
- "keycloak.{{ k3s_base_domain }}"
|
||||||
|
|
||||||
# --- platform hostname suffix, ANSIBLE SIDE (R-ENV-02, R-DNS-01) --------------------
|
# --- platform hostname suffix, ANSIBLE SIDE (R-ENV-02, R-DNS-01) --------------------
|
||||||
# PROD SERVES ITS OWN SUBDOMAIN. [parity] MUST equal BASE_DOMAIN in clusters/prod/
|
# PROD SERVES ITS OWN SUBDOMAIN. [parity] MUST equal BASE_DOMAIN in clusters/prod/
|
||||||
|
|||||||
@@ -51,11 +51,14 @@ hubble:
|
|||||||
l2announcements:
|
l2announcements:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
# k3s ships its own cgroup mount; point Cilium at it instead of auto-mounting.
|
# Point Cilium at the host's real cgroup2 root (/sys/fs/cgroup, mounted by systemd) instead of
|
||||||
|
# auto-mounting. NOT /run/cilium/cgroupv2: nothing mounts cgroup2 there, so the agent mounted its
|
||||||
|
# OWN namespaced cgroup and socket-LB covered only itself — host processes (the apiserver) lost
|
||||||
|
# Service/LB translation. [parity] platform/cilium/helmrelease.yaml explains (2026-10-03).
|
||||||
cgroup:
|
cgroup:
|
||||||
autoMount:
|
autoMount:
|
||||||
enabled: false
|
enabled: false
|
||||||
hostRoot: /run/cilium/cgroupv2
|
hostRoot: /sys/fs/cgroup
|
||||||
|
|
||||||
# Roll the cilium agent DaemonSet ONE node at a time. [parity] must match the
|
# Roll the cilium agent DaemonSet ONE node at a time. [parity] must match the
|
||||||
# platform/cilium HelmRelease. The cilium 1.20.1 chart defaults this to 2, which on a
|
# platform/cilium HelmRelease. The cilium 1.20.1 chart defaults this to 2, which on a
|
||||||
|
|||||||
@@ -242,3 +242,9 @@ node_baseline_dns_search:
|
|||||||
node_baseline_dns_options:
|
node_baseline_dns_options:
|
||||||
- timeout:2
|
- timeout:2
|
||||||
- attempts:2
|
- attempts:2
|
||||||
|
# Static /etc/hosts entries (tags: dns, hosts), as a list of {ip, names}. EMPTY by default:
|
||||||
|
# dev needs none. Prod pins its OIDC issuer host to the Gateway VIP because the public
|
||||||
|
# answer for it is the non-hairpinned WAN IP (inventory/group_vars/k3s_prod/vars.yml).
|
||||||
|
# Written as ONE marked block, so removing an entry here removes it from the node, and
|
||||||
|
# an empty list removes the block entirely.
|
||||||
|
node_baseline_hosts_pins: []
|
||||||
@@ -1090,3 +1090,22 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
when: node_baseline_dns_mechanism == 'resolv-conf-file'
|
when: node_baseline_dns_mechanism == 'resolv-conf-file'
|
||||||
tags: [dns]
|
tags: [dns]
|
||||||
|
|
||||||
|
# /etc/hosts pins — tag `dns` (and `hosts` on its own). Some node processes must reach a
|
||||||
|
# public hostname at the in-cluster Gateway, not at its public answer. On prod, the
|
||||||
|
# kube-apiserver's OIDC issuer resolves publicly to the WAN IP, which the router does not
|
||||||
|
# hairpin. glibc reads /etc/hosts before any nameserver (nsswitch `files dns`), so a pin wins
|
||||||
|
# regardless of which resolver mechanism above is in effect. One marked block: entries
|
||||||
|
# dropped from node_baseline_hosts_pins disappear from the node, and an empty list
|
||||||
|
# removes the block.
|
||||||
|
- name: Pin node-process hostnames in /etc/hosts
|
||||||
|
ansible.builtin.blockinfile:
|
||||||
|
path: /etc/hosts
|
||||||
|
marker: "# {mark} node_baseline hosts pins (Ansible, tags: hosts)"
|
||||||
|
block: |
|
||||||
|
{% for pin in node_baseline_hosts_pins %}
|
||||||
|
{{ pin.ip }} {{ pin.names | join(' ') }}
|
||||||
|
{% endfor %}
|
||||||
|
state: "{{ 'present' if node_baseline_hosts_pins | length > 0 else 'absent' }}"
|
||||||
|
backup: true
|
||||||
|
tags: [dns, hosts]
|
||||||
+12
-1
@@ -161,7 +161,18 @@ a *green* one no longer needs a signature that cannot exist.
|
|||||||
**Security-relevant changes are still reviewed** — by `ci/approval-policy.sh` against
|
**Security-relevant changes are still reviewed** — by `ci/approval-policy.sh` against
|
||||||
`approval-policy.json` `sensitive_paths`, not by this field. An administrator waives that review
|
`approval-policy.json` `sensitive_paths`, not by this field. An administrator waives that review
|
||||||
only by posting `/approve-override <reason>` on the PR: reason mandatory, permission re-checked
|
only by posting `/approve-override <reason>` on the PR: reason mandatory, permission re-checked
|
||||||
live, invalidated by a new commit, and recorded by account in the gate summary. Note that the
|
live, invalidated by a new commit, and recorded by account in the gate summary.
|
||||||
|
|
||||||
|
> **Post the override as a PR *review* (type "Comment"), not in the plain comment box** — until
|
||||||
|
> the CI token is re-minted. `SETTINGS_API_TOKEN` is `[read:repository, read:admin]`, and
|
||||||
|
> `/issues/{n}/comments` needs Gitea's *issue* scope, so Gitea answers with an error **object**
|
||||||
|
> instead of an array and the override is invisible. Measured on PR #38 (2026-10-02): a correct,
|
||||||
|
> fresh, well-formed override in the comment box produced `approval-policy: FAIL` reading as "no
|
||||||
|
> approving review" — a permission failure disguised as a missing one. `/pulls/{n}/reviews` is
|
||||||
|
> under `read:repository` and worked in the same run. The stage now reads **both** sources, names
|
||||||
|
> which one it used, and says plainly when the comments API is unreadable. `set-ci-token.sh`
|
||||||
|
> requests `read:issue` as of 2026-10-02, so **re-run it** and the comment box works too — token
|
||||||
|
> scopes cannot be widened in place. Note that the
|
||||||
**agent account `gitea-admin` also reports permission `owner`**, so it can clear its own PRs that
|
**agent account `gitea-admin` also reports permission `owner`**, so it can clear its own PRs that
|
||||||
way; that is an accepted consequence of authorising by permission alone, and the route to
|
way; that is an accepted consequence of authorising by permission alone, and the route to
|
||||||
narrowing it (an `override_admins` allowlist, already honoured by the code) is written down in
|
narrowing it (an `override_admins` allowlist, already honoured by the code) is written down in
|
||||||
|
|||||||
@@ -32,7 +32,8 @@
|
|||||||
"requires_reason": true,
|
"requires_reason": true,
|
||||||
"authorized_permissions": ["admin", "owner"],
|
"authorized_permissions": ["admin", "owner"],
|
||||||
"invalidated_by_new_commits": true,
|
"invalidated_by_new_commits": true,
|
||||||
"_how": "Comment on the PR with the directive at the START of a line, followed by a reason: '/approve-override <why>'. ci/approval-policy.sh resolves the commenter's LIVE repo permission via /collaborators/{login}/permission and accepts the override only if it is in authorized_permissions. The overrider, the timestamp and the reason are printed in the gate log and the job summary.",
|
"_how": "Post the directive at the START of a line, followed by a reason: '/approve-override <why>'. ci/approval-policy.sh resolves the poster's LIVE repo permission via /collaborators/{login}/permission and accepts the override only if it is in authorized_permissions. The overrider, the source, the timestamp and the reason are printed in the gate log and the job summary.",
|
||||||
|
"_where_to_post_it": "EITHER a PR REVIEW body (type 'Comment') OR the ordinary PR comment box. Prefer the REVIEW: measured 2026-10-02 on PR #38, a well-formed override in the comment box was INVISIBLE to CI, because /issues/{n}/comments sits behind Gitea's *issue* token scope and SETTINGS_API_TOKEN is minted [read:repository, read:admin]. Gitea returned an error object rather than an array, and the stage reported 'no approving review' -- a permission problem wearing the costume of a missing override. /pulls/{n}/reviews is under read:repository and was readable in the same run, so a review body is the route that works on today's token. infra/gitea/set-ci-token.sh now also requests read:issue, which makes the comment box work -- but only after that script is RE-RUN, since an existing token's scopes cannot be widened in place. The stage accepts both sources and names which one it used, so neither route is load-bearing alone.",
|
||||||
"_self_override_is_allowed": "The author may override their own PR if they hold an authorized permission. That is the entire point -- with one human there is no one else -- but it means the 'a change to the gate cannot be gated by the gate' protection is, for an administrator, advisory rather than structural. It is traded for an auditable record: the waiver is an explicit act, attributed, with a stated reason, in the PR's comment history and the gate output.",
|
"_self_override_is_allowed": "The author may override their own PR if they hold an authorized permission. That is the entire point -- with one human there is no one else -- but it means the 'a change to the gate cannot be gated by the gate' protection is, for an administrator, advisory rather than structural. It is traded for an auditable record: the waiver is an explicit act, attributed, with a stated reason, in the PR's comment history and the gate output.",
|
||||||
"_known_consequence_agent_accounts": "ACCEPTED, EYES OPEN (operator decision, 2026-10-01). Authorisation is by live permission ALONE, with no allowlist of human accounts. The agent account `gitea-admin` reports permission `owner` on this repo (measured 2026-10-01), so an AGENT can clear the sensitive-path review on its own PR by posting the directive. Agents author most PRs here, so this is the broad case, not the edge case. To narrow it later without changing any logic, add an `override_admins` allowlist here and require membership in addition to the permission check -- ci/approval-policy.sh reads both from this file.",
|
"_known_consequence_agent_accounts": "ACCEPTED, EYES OPEN (operator decision, 2026-10-01). Authorisation is by live permission ALONE, with no allowlist of human accounts. The agent account `gitea-admin` reports permission `owner` on this repo (measured 2026-10-01), so an AGENT can clear the sensitive-path review on its own PR by posting the directive. Agents author most PRs here, so this is the broad case, not the edge case. To narrow it later without changing any logic, add an `override_admins` allowlist here and require membership in addition to the permission check -- ci/approval-policy.sh reads both from this file.",
|
||||||
"_staleness": "An override is dropped unless its comment is NEWER than the PR head commit, mirroring Gitea's own dismiss_stale_approvals. An override earned at commit A therefore does not silently cover commit B; push again and you must override again."
|
"_staleness": "An override is dropped unless its comment is NEWER than the PR head commit, mirroring Gitea's own dismiss_stale_approvals. An override earned at commit A therefore does not silently cover commit B; push again and you must override again."
|
||||||
|
|||||||
@@ -5,9 +5,23 @@
|
|||||||
# The token value is NEVER printed. It goes from the token API into the secrets API and
|
# The token value is NEVER printed. It goes from the token API into the secrets API and
|
||||||
# nowhere else -- not to stdout, not to a file, not into an agent transcript.
|
# nowhere else -- not to stdout, not to a file, not into an agent transcript.
|
||||||
#
|
#
|
||||||
# Scope: `read:repository` + `read:admin` are what the check needs (the rule object, and the
|
# Scope: `read:repository` + `read:admin` are what ci/gitea-settings.sh needs (the rule object,
|
||||||
# runner list). Gitea token scopes are coarse; grant the least that works on your version and
|
# and the runner list). `read:issue` is for ci/approval-policy.sh, and it was MISSING — see below.
|
||||||
# confirm the check goes green rather than assuming the scope name.
|
# Gitea token scopes are coarse; grant the least that works on your version and confirm the check
|
||||||
|
# goes green rather than assuming the scope name.
|
||||||
|
#
|
||||||
|
# WHY read:issue IS HERE (added 2026-10-02, measured on PR #38). ci/approval-policy.sh reads the
|
||||||
|
# PR's comments to find an admin `/approve-override`. /issues/{n}/comments sits behind the *issue*
|
||||||
|
# scope, which this script did not request — so Gitea answered with an error OBJECT instead of an
|
||||||
|
# array, and a perfectly well-formed override posted in the PR comment box was invisible to CI.
|
||||||
|
# The failure did not look like a permission problem from the outside; it looked like the override
|
||||||
|
# had not been posted. `/pulls/{n}/files` and `/pulls/{n}/reviews` are under read:repository, which
|
||||||
|
# is why every other part of the same stage worked and this one did not.
|
||||||
|
#
|
||||||
|
# The stage now accepts an override from a REVIEW body as well, so it still functions on a token
|
||||||
|
# WITHOUT read:issue. This scope is what makes the ordinary comment box work too — the thing
|
||||||
|
# anybody would reach for first. **Re-run this script to pick it up; an existing token's scopes
|
||||||
|
# cannot be widened in place.**
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
HOST=gitea.home.keithjackson.dev; OWNER=Home-Network; REPO=home-network-k3s-impl
|
HOST=gitea.home.keithjackson.dev; OWNER=Home-Network; REPO=home-network-k3s-impl
|
||||||
# NOT GITEA_* — Gitea reserves that prefix for secret names and returns HTTP 400
|
# NOT GITEA_* — Gitea reserves that prefix for secret names and returns HTTP 400
|
||||||
@@ -21,7 +35,7 @@ basic() { curl -sS --config <(printf 'user = "%s:%s"\n' "$U" "$P") "$@"; }
|
|||||||
|
|
||||||
echo "==> minting token '${NAME}' as ${U}"
|
echo "==> minting token '${NAME}' as ${U}"
|
||||||
TOKEN="$(basic -X POST -H 'Content-Type: application/json' \
|
TOKEN="$(basic -X POST -H 'Content-Type: application/json' \
|
||||||
-d "{\"name\":\"${NAME}\",\"scopes\":[\"read:repository\",\"read:admin\"]}" \
|
-d "{\"name\":\"${NAME}\",\"scopes\":[\"read:repository\",\"read:admin\",\"read:issue\"]}" \
|
||||||
"https://${HOST}/api/v1/users/${U}/tokens" | jq -r '.sha1 // empty')"
|
"https://${HOST}/api/v1/users/${U}/tokens" | jq -r '.sha1 // empty')"
|
||||||
[ -n "$TOKEN" ] || { echo "token creation failed (check scopes for your Gitea version)" >&2; exit 1; }
|
[ -n "$TOKEN" ] || { echo "token creation failed (check scopes for your Gitea version)" >&2; exit 1; }
|
||||||
echo "==> got a ${#TOKEN}-character token (value not shown)"
|
echo "==> got a ${#TOKEN}-character token (value not shown)"
|
||||||
|
|||||||
@@ -106,14 +106,33 @@ spec:
|
|||||||
# CiliumL2AnnouncementPolicy (reserved 10.73.0.240-.250) authored by WS-NET in Phase 2.
|
# CiliumL2AnnouncementPolicy (reserved 10.73.0.240-.250) authored by WS-NET in Phase 2.
|
||||||
l2announcements:
|
l2announcements:
|
||||||
enabled: true
|
enabled: true
|
||||||
# --- cgroup: k3s ships its own cgroup mount; point Cilium at it, don't auto-mount ---
|
# --- cgroup: point Cilium at the HOST's cgroup2 root, which systemd mounts at /sys/fs/cgroup ---
|
||||||
# [parity] MUST equal Ansible cgroup block. Not in the deliverable's headline list, but it IS
|
# [parity] MUST equal Ansible cgroup block (roles/cilium/templates/cilium-values.yaml.j2).
|
||||||
# part of the working CNI config on k3s: reconciling without it lets Cilium try to auto-mount
|
#
|
||||||
# cgroupv2 and break on adopt. Kept in parity for a safe Phase-2 adoption.
|
# FIXED 2026-10-03 — hostRoot WAS /run/cilium/cgroupv2, AND THAT SILENTLY BROKE SOCKET-LB FOR
|
||||||
|
# EVERY HOST PROCESS. With autoMount disabled, the chart only bind-mounts whatever already exists
|
||||||
|
# at hostRoot ("it's expected that users have mounted cgroup2 filesystem at the specified
|
||||||
|
# cgroup.hostRoot" — cilium 1.20.1 values.yaml). Nothing ever mounted cgroup2 at
|
||||||
|
# /run/cilium/cgroupv2 on these nodes (it is an empty DirectoryOrCreate hostPath), so the agent
|
||||||
|
# mounted one ITSELF from inside its own private cgroup namespace. That mount's root is the
|
||||||
|
# agent container's OWN leaf cgroup, so cil_sock4_connect & co. were attached there and covered
|
||||||
|
# only the agent. Every other host-netns process (the k3s apiserver in
|
||||||
|
# /system.slice/k3s.service, curl over SSH) was untranslated: Service ClusterIPs timed out from
|
||||||
|
# every node, and the node holding the Gateway's L2 lease could not reach the Gateway VIP at all
|
||||||
|
# ("no route to host"). That is why k3s-prod-1's apiserver logged `oidc: authenticator not
|
||||||
|
# initialized` from 2026-09-12 on — kubectl SSO through the kube-vip VIP failed outright.
|
||||||
|
# Measured on k3s-prod-1 before this change: TCP connect to 192.168.2.20:443, 10.43.225.79:443
|
||||||
|
# and 10.43.59.141:8080 SUCCEEDED from inside the agent container (the covered cgroup) and
|
||||||
|
# FAILED from the host, same node, same netns. Full write-up:
|
||||||
|
# decisions/2026-10-03-cilium-cgroup-hostroot.md.
|
||||||
|
#
|
||||||
|
# /sys/fs/cgroup IS a cgroup2 mount in the host's root cgroup namespace on every node (verified
|
||||||
|
# with findmnt on all three), so bind-mounting it gives the agent the real host root. autoMount
|
||||||
|
# stays off because the mount it would create already exists.
|
||||||
cgroup:
|
cgroup:
|
||||||
autoMount:
|
autoMount:
|
||||||
enabled: false
|
enabled: false
|
||||||
hostRoot: /run/cilium/cgroupv2
|
hostRoot: /sys/fs/cgroup
|
||||||
# --- Agent rollout: ONE node at a time (blast-radius bound) — 2026-09-01 incident ---
|
# --- Agent rollout: ONE node at a time (blast-radius bound) — 2026-09-01 incident ---
|
||||||
# [parity] MUST equal Ansible: updateStrategy.rollingUpdate.maxUnavailable: 1.
|
# [parity] MUST equal Ansible: updateStrategy.rollingUpdate.maxUnavailable: 1.
|
||||||
# The chart's own default is 2 (cilium 1.20.1 values.yaml: updateStrategy.rollingUpdate.
|
# The chart's own default is 2 (cilium 1.20.1 values.yaml: updateStrategy.rollingUpdate.
|
||||||
|
|||||||
@@ -41,11 +41,13 @@ hubble:
|
|||||||
# [parity] ingress VIP announcement on the flat LAN (docs/09, OQ-1)
|
# [parity] ingress VIP announcement on the flat LAN (docs/09, OQ-1)
|
||||||
l2announcements:
|
l2announcements:
|
||||||
enabled: true
|
enabled: true
|
||||||
# [parity] k3s provides the cgroupv2 mount; do not auto-mount
|
# [parity] the host's cgroup2 root is /sys/fs/cgroup (systemd mounts it); do not auto-mount.
|
||||||
|
# NOT /run/cilium/cgroupv2: nothing mounts cgroup2 there, so socket-LB attached to the agent's own
|
||||||
|
# cgroup and host processes lost Service/LB translation (2026-10-03 — see helmrelease.yaml).
|
||||||
cgroup:
|
cgroup:
|
||||||
autoMount:
|
autoMount:
|
||||||
enabled: false
|
enabled: false
|
||||||
hostRoot: /run/cilium/cgroupv2
|
hostRoot: /sys/fs/cgroup
|
||||||
# [parity] roll the agent DaemonSet ONE node at a time. Overrides the cilium 1.20.1 chart
|
# [parity] roll the agent DaemonSet ONE node at a time. Overrides the cilium 1.20.1 chart
|
||||||
# default of 2, which on 3 nodes lets a single bad render break 2/3 agents at once — as the
|
# default of 2, which on 3 nodes lets a single bad render break 2/3 agents at once — as the
|
||||||
# 2026-09-01 incident did (k8sServiceHost: "" → the `config` init container fell back to the
|
# 2026-09-01 incident did (k8sServiceHost: "" → the `config` init container fell back to the
|
||||||
|
|||||||
@@ -20,6 +20,30 @@ A wildcard `*.keithjackson.dev` matches only one label deep, so `<svc>.home.keit
|
|||||||
its own record. **`internal` services need NO public record** — PiHole resolves them to the internal
|
its own record. **`internal` services need NO public record** — PiHole resolves them to the internal
|
||||||
Gateway VIP (split-horizon). ExternalDNS manages **`public`** services' records only.
|
Gateway VIP (split-horizon). ExternalDNS manages **`public`** services' records only.
|
||||||
|
|
||||||
|
## Public target + in-cluster DDNS (2026-10-03)
|
||||||
|
|
||||||
|
Gateway-published records are **CNAMEs to `wan.home.keithjackson.dev`**, not the Gateway VIP:
|
||||||
|
|
||||||
|
```
|
||||||
|
plex.home.keithjackson.dev CNAME wan.home.keithjackson.dev <- external-dns (Gateway `target` annotation)
|
||||||
|
wan.home.keithjackson.dev A <current WAN IP> <- cloudflare-ddns (ddns.yaml), every 5m
|
||||||
|
```
|
||||||
|
|
||||||
|
- The `target` annotation is on the **shared Gateway** (`platform/gateway/gateway.yaml`), so it
|
||||||
|
applies to every route that publishes at all. `internal` routes (`controller: none`) still publish
|
||||||
|
nothing.
|
||||||
|
- `ddns.yaml` runs `favonia/cloudflare-ddns` in this namespace with the same `cloudflare-api-token`.
|
||||||
|
It detects the WAN IP through Cloudflare's trace endpoint, so the pod **must not** carry
|
||||||
|
`egress-class: vpn`.
|
||||||
|
- LAN and pods resolve `*.home.keithjackson.dev` to the Gateway VIP via PiHole and CoreDNS
|
||||||
|
(split-horizon) and never use the public path.
|
||||||
|
- **Nodes resolve through public DNS** (`node_baseline_dns_servers`), so from a node, the apiserver's
|
||||||
|
OIDC issuer `keycloak.home.keithjackson.dev` resolves to the WAN IP. kubectl SSO therefore relies on
|
||||||
|
the router hairpinning (NAT reflection) WAN:443 back to the Gateway.
|
||||||
|
|
||||||
|
Check: `dig +short @1.1.1.1 plex.home.keithjackson.dev` → `wan.home.keithjackson.dev.` then the WAN IP;
|
||||||
|
`kubectl -n external-dns logs deploy/cloudflare-ddns`.
|
||||||
|
|
||||||
## Secrets
|
## Secrets
|
||||||
|
|
||||||
Cloudflare API token referenced **by name** (`cloudflare-api-token`, shared with cert-manager),
|
Cloudflare API token referenced **by name** (`cloudflare-api-token`, shared with cert-manager),
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
---
|
||||||
|
# In-cluster dynamic DNS for the home WAN (2026-10-03).
|
||||||
|
#
|
||||||
|
# Keeps ONE record, `wan.${BASE_DOMAIN}` (A, IPv4 only, not proxied), pointed at the home WAN's
|
||||||
|
# current public IP. Every public name external-dns publishes from the shared Gateway is a CNAME to
|
||||||
|
# it (platform/gateway/gateway.yaml `external-dns.alpha.kubernetes.io/target`), so a WAN IP change
|
||||||
|
# moves all of them through this one record, with no Git change and no external-dns involvement.
|
||||||
|
#
|
||||||
|
# OWNERSHIP SPLIT WITH EXTERNAL-DNS. This record carries no external-dns TXT registry entry, so
|
||||||
|
# external-dns (policy: sync, owner `prod`) never treats it as its own and never deletes it.
|
||||||
|
# Conversely this updater manages only the names in DOMAINS and never touches external-dns's records.
|
||||||
|
#
|
||||||
|
# IP DETECTION. Default `cloudflare.trace`: an HTTPS request to Cloudflare's trace endpoint, which
|
||||||
|
# reports the address the request arrived from. Correct here only because this pod is NOT in the
|
||||||
|
# `vpn` egress class — the CiliumEgressGatewayPolicy (platform/vpn-egress) selects pods by
|
||||||
|
# `homelab.keithjackson.dev/egress-class: vpn` alone, cluster-wide. Labelling this pod `vpn` would
|
||||||
|
# publish the VPN exit IP instead of the WAN IP.
|
||||||
|
#
|
||||||
|
# TOKEN. Reuses this namespace's `cloudflare-api-token` (same Zone:DNS:Edit token as external-dns
|
||||||
|
# and cert-manager) — no new secret.
|
||||||
|
#
|
||||||
|
# TIER NOTE. This lives in the gating `platform` Flux Kustomization (wait: true) next to
|
||||||
|
# external-dns. If it ever crashloops, `platform` goes not-Ready and apps stop reconciling (no
|
||||||
|
# outage). Kept here rather than in its own non-gating Kustomization to avoid a clusters/** change.
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: cloudflare-ddns
|
||||||
|
namespace: external-dns
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: cloudflare-ddns
|
||||||
|
spec:
|
||||||
|
replicas: 1 # exactly one writer for the record
|
||||||
|
revisionHistoryLimit: 3
|
||||||
|
strategy:
|
||||||
|
type: Recreate # never two updaters at once
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: cloudflare-ddns
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: cloudflare-ddns
|
||||||
|
spec:
|
||||||
|
automountServiceAccountToken: false
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 1000
|
||||||
|
runAsGroup: 1000
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
containers:
|
||||||
|
- name: cloudflare-ddns
|
||||||
|
image: favonia/cloudflare-ddns:1.17.1
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
env:
|
||||||
|
- name: CLOUDFLARE_API_TOKEN
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: cloudflare-api-token
|
||||||
|
key: api-token
|
||||||
|
- name: DOMAINS
|
||||||
|
value: "wan.${BASE_DOMAIN}"
|
||||||
|
- name: IP6_PROVIDER
|
||||||
|
value: "none" # IPv4 WAN only; leaves any AAAA alone
|
||||||
|
- name: PROXIED
|
||||||
|
value: "false" # DNS-only: Plex/Gitea/etc. terminate TLS on the Gateway
|
||||||
|
- name: UPDATE_CRON
|
||||||
|
value: "@every 5m"
|
||||||
|
- name: DELETE_ON_STOP
|
||||||
|
value: "false" # a pod restart must not blank the record every CNAME depends on
|
||||||
|
- name: RECORD_COMMENT
|
||||||
|
value: "managed by k3s cloudflare-ddns (platform/externaldns/ddns.yaml)"
|
||||||
|
- name: EMOJI
|
||||||
|
value: "false"
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 5m
|
||||||
|
memory: 16Mi
|
||||||
|
limits:
|
||||||
|
memory: 64Mi
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
capabilities:
|
||||||
|
drop:
|
||||||
|
- ALL
|
||||||
@@ -81,38 +81,29 @@ spec:
|
|||||||
# what this instance can see or own; per-resource exclusion only ever REMOVES an object from
|
# what this instance can see or own; per-resource exclusion only ever REMOVES an object from
|
||||||
# this instance's view, so it cannot make prod capable of deleting a record it does not own.
|
# this instance's view, so it cannot make prod capable of deleting a record it does not own.
|
||||||
# ---------------------------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------------------------
|
||||||
# THE PUBLIC-TARGET GAP — STILL OPEN, AND NOT FIXABLE HERE ON v0.21.0 (operator decision)
|
# THE PUBLIC TARGET — DECIDED 2026-10-03 (operator): every Gateway-published name goes public
|
||||||
#
|
#
|
||||||
# The five `exposure: public` services (plex, gitea, seerr, keycloak, nextcloud) publish records
|
# Until 2026-10-03 every gateway-sourced record answered with the Gateway VIP (192.168.2.20,
|
||||||
# that point at 192.168.2.20 — the Gateway VIP, an unroutable RFC1918 address — so they are not
|
# RFC1918), so nothing published here was reachable from the internet. Two facts constrained the
|
||||||
# actually reachable from the internet either. Their records are LEFT EXACTLY AS THEY ARE by
|
# fix, and both still hold:
|
||||||
# this change, for two independent reasons:
|
# * `external-dns.alpha.kubernetes.io/target` is read in v0.21.0's gateway-httproute source ONLY
|
||||||
|
# from the GATEWAY (source/gateway.go: `overrides: annotations.TargetsFromTargetAnnotation(
|
||||||
|
# gw.Annotations)`) and from a ListenerSet listener — NEVER from an HTTPRoute. A target on the
|
||||||
|
# shared Gateway therefore moves EVERY gateway-sourced record at once.
|
||||||
|
# * The home WAN IP is residential and can change, so a literal IP in Git would rot.
|
||||||
#
|
#
|
||||||
# 1. THE VALUE IS NOT ESTABLISHED. The one public IP this repo knows is 136.51.115.252
|
# The operator's answers: (a) yes, ALL of them — the five `exposure: public` apps AND the admin
|
||||||
# (clusters/prod/kustomization.yaml, where the zone's `*.keithjackson.dev` wildcard also
|
# UIs (auth, crafty, grafana, headlamp, the apex), which sit behind Keycloak / oauth2-proxy;
|
||||||
# points). It belongs to the LEGACY non-k3s stack and currently serves a placeholder
|
# (b) the address is kept current IN-CLUSTER. Implementation:
|
||||||
# certificate. Whether prod's public services belong behind it, behind a new forward, or
|
# * platform/gateway/gateway.yaml sets `target: wan.${BASE_DOMAIN}` on the shared Gateway, so
|
||||||
# are not meant to be public at all is an operator decision. Breaking five services' DNS
|
# each published name becomes a CNAME to that one hostname;
|
||||||
# on a guess is worse than leaving a known-wrong-but-stable state.
|
# * platform/externaldns/ddns.yaml (favonia/cloudflare-ddns) keeps `wan.${BASE_DOMAIN}` an A
|
||||||
# 2. THE MECHANISM CANNOT BE SCOPED TO THEM. `external-dns.alpha.kubernetes.io/target` is a
|
# record at the current WAN IP. That record has no external-dns TXT registry entry, so this
|
||||||
# real override, but in v0.21.0's gateway-httproute source it is read ONLY from the
|
# instance never owns or garbage-collects it.
|
||||||
# GATEWAY (source/gateway.go: `overrides: annotations.TargetsFromTargetAnnotation(
|
# Internal services are unchanged: their routes carry `controller: none` (above), which is
|
||||||
# gw.Annotations)`) and from a ListenerSet listener — NEVER from the HTTPRoute. So there is
|
# checked BEFORE the target is read, so they still publish nothing. The `service` source (the L4
|
||||||
# no per-service target override while every public service shares the `shared` Gateway.
|
# LoadBalancers crafty-mc, unifi) does not read the Gateway's annotation and still publishes its
|
||||||
# Annotating the Gateway would move EVERY gateway-sourced record, which after this change
|
# LB VIP. Whether those should go public is a separate, still-open question.
|
||||||
# still includes auth, crafty, grafana, headlamp and the apex — i.e. it would publish a
|
|
||||||
# routable address for admin UIs. That is strictly worse than the status quo.
|
|
||||||
#
|
|
||||||
# SO THE DECISION THE OPERATOR OWNS IS TWO-PART, and no knob is added here until the first half
|
|
||||||
# is answered, because a knob that cannot take effect invites someone to set it and expect one:
|
|
||||||
# (a) WHAT address should the five public names answer with — 136.51.115.252 as-is, a new
|
|
||||||
# port-forward/WAN address, or nothing (drop `exposure: public` to `internal` instead)?
|
|
||||||
# (b) HOW should they be separated from the non-public names that share the Gateway? Options,
|
|
||||||
# roughly in ascending cost: a SECOND Gateway (or a ListenerSet) carrying the target
|
|
||||||
# annotation, with only the public services' routes attached; or publishing the five names
|
|
||||||
# from the `crd` source (DNSEndpoint objects the renderer emits for `public` services),
|
|
||||||
# which restores per-service targeting without a second Gateway.
|
|
||||||
# Only (b) is a manifest change; (a) is a fact about the home WAN that has to be checked.
|
|
||||||
# ---------------------------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------------------------
|
||||||
# CROSS-CLUSTER DNS OWNERSHIP. Read before changing either knob below.
|
# CROSS-CLUSTER DNS OWNERSHIP. Read before changing either knob below.
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -8,3 +8,5 @@ resources:
|
|||||||
# SOPS-encrypted Cloudflare token (env CF_API_TOKEN) — SAME token as cert-manager, second copy in
|
# SOPS-encrypted Cloudflare token (env CF_API_TOKEN) — SAME token as cert-manager, second copy in
|
||||||
# the external-dns namespace. Flux decrypts at reconcile.
|
# the external-dns namespace. Flux decrypts at reconcile.
|
||||||
- cloudflare-api-token.sops.yaml
|
- cloudflare-api-token.sops.yaml
|
||||||
|
# DDNS for `wan.${BASE_DOMAIN}` — the CNAME target every Gateway-published record points at.
|
||||||
|
- ddns.yaml
|
||||||
@@ -7,6 +7,23 @@ kind: Gateway
|
|||||||
metadata:
|
metadata:
|
||||||
name: shared
|
name: shared
|
||||||
namespace: gateway
|
namespace: gateway
|
||||||
|
annotations:
|
||||||
|
# PUBLIC DNS TARGET (2026-10-03, operator decision). Without this, external-dns publishes every
|
||||||
|
# route on this Gateway with the Gateway's OWN address (${GATEWAY_VIP}, RFC1918), which is
|
||||||
|
# unreachable from the internet. With it, every published `<svc>.${BASE_DOMAIN}` (and the apex)
|
||||||
|
# becomes a CNAME to `wan.${BASE_DOMAIN}`, an A record that platform/externaldns/ddns.yaml keeps
|
||||||
|
# pointed at the home WAN's current public IP. The WAN forwards 80/443 to ${GATEWAY_VIP}.
|
||||||
|
#
|
||||||
|
# Scope is EVERY route on this Gateway that publishes at all — the operator chose to make the
|
||||||
|
# admin UIs (auth, grafana, headlamp, crafty, apex) publicly resolvable alongside the five
|
||||||
|
# `exposure: public` apps. `internal` services stay unpublished via their
|
||||||
|
# `external-dns.alpha.kubernetes.io/controller: none` route annotation, which external-dns checks
|
||||||
|
# BEFORE it reads this target. external-dns v0.21.0 reads `target` from the Gateway only, never
|
||||||
|
# from an HTTPRoute — see platform/externaldns/helmrelease.yaml.
|
||||||
|
#
|
||||||
|
# LAN and in-cluster clients are unaffected: PiHole and CoreDNS answer ${BASE_DOMAIN} with
|
||||||
|
# ${GATEWAY_VIP} directly (split-horizon), so they never take this public path.
|
||||||
|
external-dns.alpha.kubernetes.io/target: "wan.${BASE_DOMAIN}"
|
||||||
spec:
|
spec:
|
||||||
gatewayClassName: cilium
|
gatewayClassName: cilium
|
||||||
listeners:
|
listeners:
|
||||||
|
|||||||
+12
-8
@@ -153,14 +153,18 @@ Each affected runbook says so on its own page and gives the by-hand equivalent.
|
|||||||
plex.tv/claim — so it is a timed sequence, scripted as `infra/plex/claim-ship.sh`:
|
plex.tv/claim — so it is a timed sequence, scripted as `infra/plex/claim-ship.sh`:
|
||||||
token → `sops` → PR → gate → merge → **forced** `flux reconcile` → pod roll (~2m45s measured).
|
token → `sops` → PR → gate → merge → **forced** `flux reconcile` → pod roll (~2m45s measured).
|
||||||
Includes why a changed Secret does not restart anything, and why an expired token is harmless.
|
Includes why a changed Secret does not restart anything, and why an expired token is harmless.
|
||||||
- **`plex-quicksync`** — hardware transcoding on the host iGPU. GitOps puts `/dev/dri` in the pod
|
- **`plex-quicksync`** — hardware transcoding on the host iGPU. **Confirmed working 2026-10-02**
|
||||||
(`hardware.gpu: intel-quicksync`); **a human still has to tick Settings → Transcoder → "Use hardware
|
(VAAPI decode *and* encode, zero-copy). GitOps requests `gpu.intel.com/i915` on the pod
|
||||||
acceleration when available"**, and no environment variable can do it (the image supports no
|
(`hardware.gpu: intel-quicksync`, satisfied by the `intel-gpu-plugin` DaemonSet — a `/dev/dri`
|
||||||
`PLEX_PREFERENCE*`, verified). The page names the real preference key (`HardwareAcceleratedCodecs`,
|
hostPath was measured not to work: device-cgroup `EPERM`); **a human still has to tick Settings →
|
||||||
read out of the binary), says which widely-cited key **does not exist**, and is explicit that the
|
Transcoder → "Use hardware acceleration when available"**, and no environment variable can do it
|
||||||
**only** proof is `Transcode (hw)` on the Plex dashboard — Plex renders no ServiceMonitor, so no
|
(the image supports no `PLEX_PREFERENCE*`, verified). **The proof is `Plex Media Server.log`'s
|
||||||
alert could ever fire. Also: the device-cgroup failure to expect first and its device-plugin
|
`final decoder: / final encoder:` line, not the Plex dashboard** — the dashboard showed nothing
|
||||||
fallback, and why a `:latest` pull can silently break it.
|
while hardware transcoding was working, so its silence proves nothing; the page carries the
|
||||||
|
redacting grep (`X-Plex-Token` lives in that log), the WRONG log file to avoid, and two more
|
||||||
|
documented false leads (an empty `/proc/*/fd` scan, and a missing `HardwareAcceleratedCodecs` key).
|
||||||
|
Plex renders no ServiceMonitor, so no alert could ever fire. Also: **`Pending` stalls the whole app
|
||||||
|
tier** and its one-field escape hatch, and why a `:latest` pull can silently break it.
|
||||||
- **`oauth2-proxy-session`** — a gated UI loads but its saves quietly do nothing, and you are never
|
- **`oauth2-proxy-session`** — a gated UI loads but its saves quietly do nothing, and you are never
|
||||||
re-prompted to log in. Tells the three stacked auth layers apart from the sidecar's own access log
|
re-prompted to log in. Tells the three stacked auth layers apart from the sidecar's own access log
|
||||||
(the four lines that matter, including the upstream `-` that means the proxy answered a request the
|
(the four lines that matter, including the upstream `-` that means the proxy answered a request the
|
||||||
|
|||||||
+194
-55
@@ -1,5 +1,10 @@
|
|||||||
# plex-quicksync — making Plex actually use Intel Quick Sync, and proving it did
|
# plex-quicksync — making Plex actually use Intel Quick Sync, and proving it did
|
||||||
|
|
||||||
|
**Status: hardware transcoding is CONFIRMED WORKING on prod as of 2026-10-02** — VAAPI for both
|
||||||
|
decode *and* encode, zero-copy, measured in Plex's own server log (see "How to verify"). The manual
|
||||||
|
toggle has been ticked. This page is therefore now mostly a **verification and regression**
|
||||||
|
procedure, and its old verification section was **wrong** — see the correction at the top of
|
||||||
|
"How to verify".
|
||||||
**Alert:** none, and none is possible — see "How to verify" below. This is an **operator procedure**,
|
**Alert:** none, and none is possible — see "How to verify" below. This is an **operator procedure**,
|
||||||
not an incident page. It is indexed here because the feature it completes is **inert until a human
|
not an incident page. It is indexed here because the feature it completes is **inert until a human
|
||||||
clicks one button**, and a switched-off feature that looks shipped is exactly what this page exists
|
clicks one button**, and a switched-off feature that looks shipped is exactly what this page exists
|
||||||
@@ -33,7 +38,7 @@ is in git**:
|
|||||||
| Half | Who does it | State |
|
| Half | Who does it | State |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| The render device is inside the container **and openable** | **GitOps.** `hardware.gpu: intel-quicksync` in `catalog/plex.yaml` renders `resources.limits: {gpu.intel.com/i915: 1}` on the app container. The `intel-gpu-plugin` DaemonSet (`platform/intel-gpu-plugin/`) injects the device **and the matching device-cgroup allow rule**. Flux applies it. | Done by merging that change. |
|
| The render device is inside the container **and openable** | **GitOps.** `hardware.gpu: intel-quicksync` in `catalog/plex.yaml` renders `resources.limits: {gpu.intel.com/i915: 1}` on the app container. The `intel-gpu-plugin` DaemonSet (`platform/intel-gpu-plugin/`) injects the device **and the matching device-cgroup allow rule**. Flux applies it. | Done by merging that change. |
|
||||||
| Plex is *told* to use it | **The operator, by hand, in the Plex web UI.** | **NOT done.** `HardwareAcceleratedCodecs` was **absent** from `Preferences.xml` when this page was written — the toggle has never been on. |
|
| Plex is *told* to use it | **The operator, by hand, in the Plex web UI.** | **Done, 2026-10-02**, and confirmed from the **log** rather than from a preference key. `HardwareAcceleratedCodecs` is **still absent** from `Preferences.xml` with hardware transcoding working, so its absence proves nothing — see first-command #4. |
|
||||||
|
|
||||||
**There is no GitOps path to the second half, and that is verified rather than assumed.** A bounded
|
**There is no GitOps path to the second half, and that is verified rather than assumed.** A bounded
|
||||||
grep across the running image's entire init surface — `/etc/cont-init.d`, `/etc/services.d`,
|
grep across the running image's entire init surface — `/etc/cont-init.d`, `/etc/services.d`,
|
||||||
@@ -79,8 +84,8 @@ also the only thing that produces the cgroup allow rule. There is no graceful-de
|
|||||||
The one improvement is that the loss is no longer silent.
|
The one improvement is that the loss is no longer silent.
|
||||||
|
|
||||||
What this does **not** do, so nobody reads more into a green gate than is there: it does not enable
|
What this does **not** do, so nobody reads more into a green gate than is there: it does not enable
|
||||||
hardware transcoding (that is the manual toggle below), a green gate only proves the **manifest
|
hardware transcoding (that is the manual toggle below — done 2026-10-02), a green gate only proves
|
||||||
renders**, and it writes no `nodeSelector` — the resource itself constrains Plex to nodes the plugin
|
the **manifest renders**, and it writes no `nodeSelector` — the resource itself constrains Plex to nodes the plugin
|
||||||
advertises, which is the scheduler's doing and not a selector in this repo.
|
advertises, which is the scheduler's doing and not a selector in this repo.
|
||||||
|
|
||||||
## Running the commands in this runbook
|
## Running the commands in this runbook
|
||||||
@@ -172,17 +177,28 @@ cgroup rule that uid 0 could not buy its way past either. `RW-OPEN-DENIED` with
|
|||||||
visible is the **device-cgroup** failure — i.e. the plugin's allow rule did not arrive; see "Likely
|
visible is the **device-cgroup** failure — i.e. the plugin's allow rule did not arrive; see "Likely
|
||||||
causes" #2.
|
causes" #2.
|
||||||
|
|
||||||
**4. Is the toggle on?** Named key only — never the whole file:
|
**4. Is the toggle on?** **You cannot answer this from `Preferences.xml` alone.** That mistake is on
|
||||||
|
this page's record: it told readers to treat a missing `HardwareAcceleratedCodecs` as "never
|
||||||
|
enabled", and on 2026-10-02 that key was **absent from all 27 attributes** while hardware
|
||||||
|
transcoding was demonstrably working. Named keys only — never the whole file, it holds
|
||||||
|
`PlexOnlineToken`:
|
||||||
|
|
||||||
```
|
```
|
||||||
infra/devcontainer/dev.sh run bash -c \
|
infra/devcontainer/dev.sh run bash -c \
|
||||||
'kubectl --context k3s-prod -n plex exec deploy/plex -- \
|
'kubectl --context k3s-prod -n plex exec deploy/plex -- \
|
||||||
grep -o "HardwareAcceleratedCodecs=\"[01]\"" \
|
grep -o -e "HardwareAcceleratedCodecs=\"[01]\"" -e "HardwareDevicePath=\"[^\"]*\"" \
|
||||||
"/config/Library/Application Support/Plex Media Server/Preferences.xml" || echo ABSENT'
|
"/config/Library/Application Support/Plex Media Server/Preferences.xml" || echo NEITHER-KEY'
|
||||||
```
|
```
|
||||||
|
|
||||||
`ABSENT` ⇒ never enabled (the state this page was written in). `="0"` ⇒ explicitly off. `="1"` ⇒ on,
|
| Reading | What it actually means |
|
||||||
and the remaining question is whether it *works* — go to "How to verify".
|
|---|---|
|
||||||
|
| `HardwareDevicePath="…"` **present** | **Positive signal.** Plex writes this when hardware acceleration is configured; it was present on 2026-10-02 alongside a working VAAPI pipeline. |
|
||||||
|
| `HardwareAcceleratedCodecs` **absent** | **Nothing. Neither on nor off.** Measured absent with the feature working (2026-10-02). Do not diagnose from it, and do not write it into the file to "fix" it. |
|
||||||
|
| `HardwareAcceleratedCodecs="0"` | explicitly **off** — the only conclusive reading, and only in the negative. |
|
||||||
|
| `HardwareAcceleratedCodecs="1"` | on. |
|
||||||
|
|
||||||
|
**The log settles it, this file does not.** Go to "How to verify"; the `hardware transcoding:
|
||||||
|
enabled` and `final decoder/final encoder` lines are the verdict.
|
||||||
|
|
||||||
## The one manual step
|
## The one manual step
|
||||||
|
|
||||||
@@ -191,8 +207,11 @@ Plex web UI → **Settings → Transcoder → "Use hardware acceleration when av
|
|||||||
|
|
||||||
The underlying preference key is **`HardwareAcceleratedCodecs`**. That name was confirmed by
|
The underlying preference key is **`HardwareAcceleratedCodecs`**. That name was confirmed by
|
||||||
extracting strings from the 23 MB `Plex Media Server` binary in the running container — **not** from
|
extracting strings from the 23 MB `Plex Media Server` binary in the running container — **not** from
|
||||||
recall and not from a forum post. Real neighbouring keys from the same binary, so you can recognise
|
recall and not from a forum post. **But do not expect to see it in `Preferences.xml` after ticking
|
||||||
them and not invent them:
|
the box:** on 2026-10-02, with the toggle on and hardware transcoding working, the key was absent and
|
||||||
|
`HardwareDevicePath` was what had been written. The key exists in the binary; Plex does not
|
||||||
|
necessarily persist it. Real neighbouring keys from the same binary, so you can recognise them and
|
||||||
|
not invent them:
|
||||||
|
|
||||||
| Key | What it is |
|
| Key | What it is |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -209,42 +228,121 @@ binary. Recorded explicitly so nobody spends an evening hunting for it or, worse
|
|||||||
Editing `Preferences.xml` by hand is **not** the procedure. It is app state on the replicated Longhorn
|
Editing `Preferences.xml` by hand is **not** the procedure. It is app state on the replicated Longhorn
|
||||||
tier, it is in no git commit, and the UI writes the key correctly. Use the UI.
|
tier, it is in no git commit, and the UI writes the key correctly. Use the UI.
|
||||||
|
|
||||||
## How to verify — and why there is no alert
|
## How to verify — the log, not the dashboard
|
||||||
|
|
||||||
Start a playback that **forces a transcode** (a client that cannot direct-play the source, or pick a
|
**Correction, 2026-10-02.** Until today this page said the Plex dashboard showing `Transcode (hw)`
|
||||||
lower quality explicitly), then read Plex's own **Dashboard → Activity**:
|
was "the *only* proof available, and nothing else will ever tell you". **That was false and it cost
|
||||||
|
the operator real time:** hardware transcoding was fully working — VAAPI decode *and* encode, with
|
||||||
|
zero-copy — while **Dashboard → Activity showed nothing of the sort**. A check that is silent while
|
||||||
|
the feature works does not just fail to prove anything; it sends someone hunting a fault that does
|
||||||
|
not exist. The authoritative check is **Plex's own server log**.
|
||||||
|
|
||||||
- **`Transcode (hw)`** on the session ⇒ hardware transcoding. **This is the proof.**
|
### The primary check — run this one
|
||||||
- plain **`Transcode`** ⇒ software. The feature is not working, whatever the manifest says.
|
|
||||||
|
|
||||||
**That dashboard is the *only* proof available, and nothing else will ever tell you.**
|
```
|
||||||
`catalog/plex.yaml` declares no `metricsEndpoint`, so Plex renders **no ServiceMonitor at all** —
|
./infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod -n plex exec deploy/plex -- \
|
||||||
there is no scrape, no transcode-mode metric, and therefore no alert that could fire. Do not open work
|
grep -aiE "hardware transcoding: (final|using)" \
|
||||||
to "alert on software-transcode fallback": with no metric behind it such a rule would be permanently
|
"/config/Library/Application Support/Plex Media Server/Logs/Plex Media Server.log" \
|
||||||
inert, and this repo has already been burned at scale by rules that evaluated against nothing while
|
| sed -E "s/(X-Plex-Token|token)=[^&[:space:]]*/\1=REDACTED/g" | tail -5'
|
||||||
looking like coverage (`runbooks/README.md`, "Alerts you should not trust the silence of"). If that
|
```
|
||||||
gap is ever worth closing, the honest order is a real exporter first and a rule second.
|
|
||||||
|
|
||||||
Corroborating, not a substitute — the server log on the config PVC:
|
| Line | Verdict |
|
||||||
|
|---|---|
|
||||||
|
| `hardware transcoding: final decoder: vaapi, final encoder: vaapi` | **PASS.** Hardware in **both** directions. This is the pass condition. |
|
||||||
|
| `hardware transcoding: using zero-copy transcoding` | Best case — frames never round-trip through the CPU. Observed on prod 2026-10-02. |
|
||||||
|
| `hardware transcoding: final decoder: , final encoder: ` (both empty) | It **fell back to software** for that session. |
|
||||||
|
| no matching line at all | No transcode has been attempted since the log rolled — force one (below), then look again. Absence is not a failure. |
|
||||||
|
|
||||||
|
The 2026-10-02 reading on prod, in full, for the shape to recognise:
|
||||||
|
|
||||||
|
```
|
||||||
|
Codecs: hardware transcoding: testing API vaapi for device '/dev/dri/renderD128'
|
||||||
|
(Intel CoffeeLake-S GT2 [UHD Graphics 630])
|
||||||
|
hardware transcoding: using hardware decode accelerator vaapi
|
||||||
|
hardware transcoding: zero-copy support present
|
||||||
|
hardware transcoding: using zero-copy transcoding
|
||||||
|
hardware transcoding: final decoder: vaapi, final encoder: vaapi
|
||||||
|
```
|
||||||
|
|
||||||
|
To make a line appear, start a playback that **forces a transcode**: a client that cannot
|
||||||
|
direct-play the source, or an explicitly lower quality. Direct Play / Direct Stream never reaches
|
||||||
|
the transcoder and logs nothing.
|
||||||
|
|
||||||
|
### Three things to get right about that command
|
||||||
|
|
||||||
|
1. **Always keep the `sed`.** `Plex Media Server.log` carries API URLs containing
|
||||||
|
**`X-Plex-Token=…`**, a live plex.tv credential. Any output that might land in a ticket, a
|
||||||
|
transcript or a chat window must be redacted — CLAUDE.md §6, and a credential has already been
|
||||||
|
leaked into a transcript in this repo exactly this way. The redacting form above is the form to
|
||||||
|
copy; do not "simplify" the pipe out of it.
|
||||||
|
2. **`Plex Transcoder Statistics.log` is the WRONG FILE.** The name is irresistible and someone will
|
||||||
|
reach for it. It holds XML `<Variant .../>` session records — **not** ffmpeg/VAAPI output. There
|
||||||
|
is no decoder/encoder verdict anywhere in it. The file you want is `Plex Media Server.log`.
|
||||||
|
3. `grep -a` is deliberate: the log carries occasional non-UTF-8 bytes, and without `-a` GNU grep
|
||||||
|
reports "binary file matches" and prints nothing — which reads exactly like "no hardware
|
||||||
|
transcoding".
|
||||||
|
|
||||||
|
### Secondary and unreliable: the Plex dashboard
|
||||||
|
|
||||||
|
**Dashboard → Activity** *may* label a transcoding session `Transcode (hw)`. If it does, it agrees
|
||||||
|
with the log and you are done. **If it does not, that proves nothing at all** — measured 2026-10-02:
|
||||||
|
hardware transcoding active and zero-copy, dashboard silent. Never conclude "software transcoding"
|
||||||
|
from the dashboard and never open an investigation on its say-so. Kept here only because it is the
|
||||||
|
fastest glance available; it is a convenience, **not evidence**.
|
||||||
|
|
||||||
|
### Process-level, while a transcode is in flight: the transcoder's argv
|
||||||
|
|
||||||
|
This shows Plex *requesting* hardware, as distinct from reporting it afterwards. Get a shell in the
|
||||||
|
pod (if `-it` has no TTY under `dev.sh run`, wrap each line in `sh -c "…"` instead):
|
||||||
|
|
||||||
```
|
```
|
||||||
infra/devcontainer/dev.sh run bash -c \
|
infra/devcontainer/dev.sh run bash -c \
|
||||||
'kubectl --context k3s-prod -n plex exec deploy/plex -- \
|
'kubectl --context k3s-prod -n plex exec -it deploy/plex -- sh'
|
||||||
sh -c "grep -i -m20 -e hardware -e vaapi -e hwdevice \
|
|
||||||
\"/config/Library/Application Support/Plex Media Server/Logs/Plex Media Server.log\""'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Treat those strings as advisory: they are Plex's, not this repo's, and they move between versions.
|
then, inside the pod:
|
||||||
Absence of a match is **not** evidence of software transcoding; a VA-API device open is good evidence
|
|
||||||
of hardware. The dashboard remains the verdict.
|
```
|
||||||
|
ps -eo pid,user,comm | grep -i transcoder
|
||||||
|
tr '\0' ' ' < /proc/<transcoder-pid>/cmdline | grep -oE '(-hwaccel[a-z_]*|vaapi|renderD[0-9]+)'
|
||||||
|
```
|
||||||
|
|
||||||
|
`-hwaccel`, `-hwaccel_device`, `vaapi`, `renderD128` present ⇒ Plex is asking for hardware. Keep the
|
||||||
|
`grep -oE`: the raw argv contains media paths and session identifiers you do not want in a
|
||||||
|
transcript. This only works while a session is actually transcoding.
|
||||||
|
|
||||||
|
### Documented false lead: scanning `/proc/*/fd` for a holder of `renderD128`
|
||||||
|
|
||||||
|
**It returns nothing even when hardware transcoding is working.** Measured 2026-10-02: a
|
||||||
|
`/proc/*/fd` sweep inside the pod found **no** process holding `/dev/dri/renderD128`, while the log
|
||||||
|
showed `final decoder: vaapi, final encoder: vaapi` for sessions either side of the scan. Transcode
|
||||||
|
sessions are **short** and the scan landed in a gap between two of them. An empty fd scan is a
|
||||||
|
**timing artefact, not a finding** — do not report it as evidence of software transcoding, and do
|
||||||
|
not escalate on it. Use the argv check above (which has the same timing constraint but tells you
|
||||||
|
something when it does catch a session), or just read the log, which is retrospective and therefore
|
||||||
|
does not care about timing.
|
||||||
|
|
||||||
|
### Why there is no alert — this half was always right
|
||||||
|
|
||||||
|
`catalog/plex.yaml` declares no `metricsEndpoint`, so Plex renders **no ServiceMonitor at all** —
|
||||||
|
there is no scrape, no transcode-mode metric, and therefore no alert that could fire. Do not open
|
||||||
|
work to "alert on software-transcode fallback": with no metric behind it such a rule would be
|
||||||
|
permanently inert, and this repo has already been burned at scale by rules that evaluated against
|
||||||
|
nothing while looking like coverage (`runbooks/README.md`, "Alerts you should not trust the silence
|
||||||
|
of"). If that gap is ever worth closing, the honest order is a real exporter first and a rule
|
||||||
|
second.
|
||||||
|
|
||||||
## Likely causes, most likely first
|
## Likely causes, most likely first
|
||||||
|
|
||||||
When the dashboard still says plain `Transcode`:
|
When the **log** says it fell back to software — `final decoder: , final encoder: ` with both
|
||||||
|
fields empty. (Not when the dashboard is quiet: the dashboard's silence means nothing, see "How to
|
||||||
|
verify".)
|
||||||
|
|
||||||
1. **The toggle is off.** Overwhelmingly the most likely answer, and the reason this page exists.
|
1. **The toggle is off.** Historically the most likely answer and the reason this page exists. It was
|
||||||
First-command #4 returns `ABSENT` or `="0"`. Remedy: the manual step above. Check this **before**
|
ticked on 2026-10-02 and should stay ticked, but a `/config` restore or a stray UI save can unset
|
||||||
investigating anything technical.
|
it. Remedy: the manual step above. **Do not diagnose this from a missing
|
||||||
|
`HardwareAcceleratedCodecs`** — see first-command #4; only `="0"` is conclusive, and the log line
|
||||||
|
`hardware transcoding: enabled` is the real signal that the toggle took.
|
||||||
|
|
||||||
2. **The device cannot be `open()`ed — the plugin's cgroup allow rule did not arrive.** Symptom:
|
2. **The device cannot be `open()`ed — the plugin's cgroup allow rule did not arrive.** Symptom:
|
||||||
first-command #3 shows `renderD128` present and correctly owned, and the open returns
|
first-command #3 shows `renderD128` present and correctly owned, and the open returns
|
||||||
@@ -276,13 +374,27 @@ When the dashboard still says plain `Transcode`:
|
|||||||
4. **The image changed under you.** See "The floating-image caveat". A `:latest` pull can remove the
|
4. **The image changed under you.** See "The floating-image caveat". A `:latest` pull can remove the
|
||||||
mechanism this feature leans on with no commit in this repo.
|
mechanism this feature leans on with no commit in this repo.
|
||||||
|
|
||||||
5. **The session was never transcoding.** Direct Play / Direct Stream never touches the transcoder, so
|
5. **The session was never transcoding.** Direct Play / Direct Stream never reaches the transcoder,
|
||||||
`Transcode (hw)` cannot appear. Confirm the session actually says `Transcode` before treating it as
|
so no `hardware transcoding:` line is written for it at all. An *absent* line is this, not a
|
||||||
a hardware failure.
|
failure — confirm a transcode actually happened before treating anything as a hardware fault.
|
||||||
|
|
||||||
6. **The source needs a codec Quick Sync on this generation cannot handle** — e.g. AV1 decode, which
|
6. **The *source* codec may be one this iGPU cannot decode — HYPOTHESIS, not a confirmed
|
||||||
Coffee Lake's UHD 630 has no engine for. Plex falls back to software *per stream*. Symptom: some
|
diagnosis.** The symptom that points here is fallback on **particular titles** while other
|
||||||
sessions show `(hw)` and others do not. Not a fault.
|
sessions are hardware. Measured on prod 2026-10-02, *between* two fully hardware sessions, one
|
||||||
|
session logged:
|
||||||
|
|
||||||
|
```
|
||||||
|
hardware transcoding: enabled, but no hardware decode accelerator found
|
||||||
|
hardware transcoding: final decoder: , final encoder:
|
||||||
|
```
|
||||||
|
|
||||||
|
The platform was demonstrably working either side of it, so the likely explanation is the **file**,
|
||||||
|
not the cluster: UHD 630's media engine decodes H.264, HEVC 8-bit and 10-bit, and VP9 — but **not
|
||||||
|
AV1**, and not 10-bit 4:2:2 HEVC. **This was NOT verified against that particular title.** It is
|
||||||
|
inferred from the hardware's capability set and is written here as the lead to check first, not as
|
||||||
|
a finding. So: fallback on *all* files is a platform problem (causes 1-4); fallback on *some*
|
||||||
|
files, check the source codec before suspecting the device, and expect occasional legitimate
|
||||||
|
per-title fallbacks to be normal rather than a fault.
|
||||||
|
|
||||||
## Plex will not schedule
|
## Plex will not schedule
|
||||||
|
|
||||||
@@ -360,19 +472,30 @@ infra/devcontainer/dev.sh run bash -c \
|
|||||||
```
|
```
|
||||||
|
|
||||||
`45-plex-hw-transcode-and-connected-tuner` still present ⇒ the group mechanism is still the one
|
`45-plex-hw-transcode-and-connected-tuner` still present ⇒ the group mechanism is still the one
|
||||||
described here. Gone or renamed ⇒ stop and re-measure. In particular the "no `supplementalGroups`
|
described here: the hook reads the injected device's **own** ownership, which under the device plugin
|
||||||
needed" conclusion in `catalog/plex.yaml` and `schemas/renderer-contract.md` is only true while that
|
is `root:root`, so it resolves GID `0` (see "When to escalate or stop"). Gone or renamed ⇒ stop and
|
||||||
hook exists.
|
re-measure. In particular the "no `supplementalGroups` needed" conclusion in `catalog/plex.yaml` and
|
||||||
|
`schemas/renderer-contract.md` is only true while that hook exists.
|
||||||
|
|
||||||
## When to escalate or stop
|
## When to escalate or stop
|
||||||
|
|
||||||
- **Do not add `supplementalGroups` or a `podSecurityContext` to `catalog/plex.yaml`.** It would be
|
- **Do not add `supplementalGroups` or a `podSecurityContext` to `catalog/plex.yaml`.** It would be
|
||||||
**inert**, and it would mislead the next person. Measured in-container 2026-09-30: the image's
|
**inert**, and it would mislead the next person. The image's `cont-init.d` hook stats each
|
||||||
`cont-init.d` hook stats each `/dev/dri` char device and `usermod -a -G`s the `plex` user into its
|
`/dev/dri` char device and `usermod -a -G`s the `plex` user into **that device's** group, inside
|
||||||
group *inside* the container, and the service then execs via `s6-setuidgid plex`, which **replaces**
|
the container; the service then execs via `s6-setuidgid plex`, which **rebuilds** the process group
|
||||||
the process group list from `/etc/group` rather than appending — `Plex Media Server` runs with
|
list from `/etc/group` rather than appending to it. Anything Kubernetes supplies is therefore
|
||||||
`Groups: 100 1000`, root's gid `0` (which PID 1 does hold) gone. Anything Kubernetes supplies is
|
|
||||||
discarded before Plex starts. `render.py --selftest` asserts that absence deliberately.
|
discarded before Plex starts. `render.py --selftest` asserts that absence deliberately.
|
||||||
|
- **GID `992` is a hostPath-era number — do not expect it, and do not read the root group as a
|
||||||
|
defect.** Under the device plugin the injected `/dev/dri/renderD128` arrives **`root:root`**, so
|
||||||
|
the hook resolves GID **`0`** and adds `plex` to the **root group**. Measured 2026-10-02:
|
||||||
|
`Plex Media Server` runs with `Groups: 0 100 1000`, and `id plex` reports
|
||||||
|
`groups=1000(plex),0(root),100(users)`. Under the old hostPath the device was `root:render`
|
||||||
|
(GID 992 on these nodes) and the reading was `Groups: 44 100 992 1000`. **992 is now irrelevant**:
|
||||||
|
if any page, comment or older handoff tells you to expect it, that text is stale and the system
|
||||||
|
you are looking at is working. The root-group membership is an inherent property of the plugin's
|
||||||
|
`DeviceSpec` — it injects the host device with its host ownership — **not** a defect, and **not** a
|
||||||
|
reason to reintroduce `supplementalGroups`, which would still be a no-op for the reason in the
|
||||||
|
bullet above.
|
||||||
- **Do not reach for `privileged: true`.** Kyverno `restrict-host-namespaces` is Enforce and will
|
- **Do not reach for `privileged: true`.** Kyverno `restrict-host-namespaces` is Enforce and will
|
||||||
reject it — and it would not help: `uid 0` failed the same `open()` in the 2026-09-30 measurement,
|
reject it — and it would not help: `uid 0` failed the same `open()` in the 2026-09-30 measurement,
|
||||||
because root does not bypass the cgroup device gate.
|
because root does not bypass the cgroup device gate.
|
||||||
@@ -390,7 +513,8 @@ hook exists.
|
|||||||
watch state (`policy: daily-offcluster`). The transcode toggle is one preference, not a reason to
|
watch state (`policy: daily-offcluster`). The transcode toggle is one preference, not a reason to
|
||||||
lose the DB.
|
lose the DB.
|
||||||
- **Stop and get the operator** if the device cannot be opened from the pod as uid 1000 (cause #2
|
- **Stop and get the operator** if the device cannot be opened from the pod as uid 1000 (cause #2
|
||||||
confirmed) *under the current mechanism*. The next step is a platform change in
|
confirmed) *under the current mechanism* — note that this open **passed** on 2026-10-02
|
||||||
|
(`RW-OPEN-OK` as uid 1000 via `s6-setuidgid plex`), so a failure now is a regression. The next step is a platform change in
|
||||||
`platform/intel-gpu-plugin/`, with its own PR, not more poking at this workload.
|
`platform/intel-gpu-plugin/`, with its own PR, not more poking at this workload.
|
||||||
|
|
||||||
## Known gaps, deliberately not fixed here
|
## Known gaps, deliberately not fixed here
|
||||||
@@ -413,10 +537,25 @@ Recorded so they are findable, and so nobody assumes they were overlooked:
|
|||||||
|
|
||||||
---
|
---
|
||||||
Source of truth: `runbooks/plex-quicksync.md`. Index: `/runbooks/`
|
Source of truth: `runbooks/plex-quicksync.md`. Index: `/runbooks/`
|
||||||
The node measurements (all three prod nodes) and the in-container image measurements cited here were
|
|
||||||
taken **2026-09-30** against `plexinc/pms-docker` digest `sha256:e0ab27395614…`
|
**Hardware transcoding is CONFIRMED WORKING on prod, measured 2026-10-02.** From
|
||||||
(`plexmediaserver 1.43.4.10903`). The `EPERM` open measurements, the `Groups: 44 100 992 1000`
|
`Plex Media Server.log` in the running pod: API `vaapi` on `/dev/dri/renderD128`
|
||||||
reading and the `gpu.intel.com/i915: 1` allocatable/DaemonSet readings were taken **2026-10-01**.
|
(`Intel CoffeeLake-S GT2 [UHD Graphics 630]`), `using hardware decode accelerator vaapi`,
|
||||||
Hardware transcoding has **still not been observed working**: the open test has not been re-run under
|
`zero-copy support present`, `using zero-copy transcoding`, and
|
||||||
the device-plugin mechanism (it cannot be, until that merges and Flux reconciles), and the Plex-side
|
`final decoder: vaapi, final encoder: vaapi`. Also measured in the same pod: `RW-OPEN-OK` opening
|
||||||
toggle is still unset.
|
`/dev/dri/renderD128` as uid 1000 via `s6-setuidgid plex`; the pod spec carrying
|
||||||
|
`{"limits":{"gpu.intel.com/i915":"1"},"requests":{"gpu.intel.com/i915":"1"}}`; `Groups: 0 100 1000`
|
||||||
|
and `id plex` → `groups=1000(plex),0(root),100(users)`; and `HardwareDevicePath` present in
|
||||||
|
`Preferences.xml` while `HardwareAcceleratedCodecs` was absent from all 27 attributes.
|
||||||
|
|
||||||
|
Earlier readings, kept for provenance and **superseded** where they conflict: the node measurements
|
||||||
|
(all three prod nodes) and the in-container image measurements taken **2026-09-30** against
|
||||||
|
`plexinc/pms-docker` digest `sha256:e0ab27395614…` (`plexmediaserver 1.43.4.10903`); the hostPath
|
||||||
|
`EPERM` open results and the `Groups: 44 100 992 1000` / GID-992 group reading, **2026-10-01, under
|
||||||
|
the hostPath mechanism that no longer exists**; and the `gpu.intel.com/i915: 1` allocatable and
|
||||||
|
`intel-gpu-plugin` DaemonSet readings, **2026-10-01**.
|
||||||
|
|
||||||
|
What this page still does **not** establish: that any particular title's software fallback was caused
|
||||||
|
by its source codec (cause 6 is an unverified hypothesis), and that Plex's dashboard *ever* labels a
|
||||||
|
session `Transcode (hw)` on this deployment — it has not been observed doing so, which is exactly why
|
||||||
|
the log is now the primary check.
|
||||||
Reference in new issue
Block a user