Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01679a0681 | ||
|
|
ea0c0632d2 | ||
|
|
c59fe9f923 | ||
|
|
72a46ad82d | ||
|
|
cadf64a514 | ||
|
|
e993d0f7d1 |
No files matched your search
@@ -15,7 +15,7 @@ it touches is green** (R-TEST-03).
|
||||
## The pipeline
|
||||
|
||||
```
|
||||
lint ─▶ l0 (schema + Kyverno) ─▶ render-golden ─▶ l1 (rendering) ─▶ conformance (L2/L3) ─▶ merge-gate decision
|
||||
decisions-log ─▶ lint ─▶ l0 (schema + Kyverno) ─▶ render-golden ─▶ l1 (rendering) ─▶ conformance (L2/L3) ─▶ merge-gate decision
|
||||
└──────────────── run in CI, NO cluster ────────────────┘ └── in-cluster Chainsaw Job ──┘
|
||||
live now cluster-gated (SKIPS in Phase 0)
|
||||
```
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# CLAUDE.md — how work lands in this repo
|
||||
|
||||
Read this before doing anything durable. It is the delivery contract for AI coding
|
||||
sessions and agents. `DECISIONS-LOG.md`'s last entry is the current PM handoff and
|
||||
sessions and agents. The newest entry in `decisions/` is the current PM handoff and
|
||||
takes precedence on *what* to work on; this file governs *how* it lands.
|
||||
|
||||
## 1. Never push to `main`. Ever.
|
||||
@@ -48,9 +48,10 @@ reports as `success`, so a green tick is not by itself evidence that anything ra
|
||||
|
||||
## 2b. Never infer platform state from this repository
|
||||
|
||||
**This repo is not evidence about the live platform.** Neither is `DECISIONS-LOG.md`.
|
||||
Both describe what someone believed at the time of writing, and both have been
|
||||
confidently wrong for a month at a stretch.
|
||||
**This repo is not evidence about the live platform.** Neither is the handoff log
|
||||
(`decisions/`, and the frozen `DECISIONS-LOG.md` archive). Both describe what someone
|
||||
believed at the time of writing, and both have been confidently wrong for a month at a
|
||||
stretch.
|
||||
|
||||
On 2026-09-29 the log stated the merge gate was a required status check with a runner
|
||||
online. The API said `branch_protections: []` and **zero** runners. Everything downstream
|
||||
@@ -139,9 +140,32 @@ invocations concurrently.
|
||||
flow. Do not design around committing one — read the expiry from the issuing page and
|
||||
treat a burnt token as free to retry.
|
||||
|
||||
## 7. Before you finish
|
||||
## 7. Before you finish — write a handoff entry, in its OWN file
|
||||
|
||||
Append a handoff entry to `DECISIONS-LOG.md` describing what you changed, what you
|
||||
verified, and what you did **not** verify. State gaps plainly — an honest
|
||||
"UNVERIFIED" is worth more than an optimistic "green", and this repo has already been
|
||||
burned by the difference.
|
||||
Add **one new file**:
|
||||
|
||||
decisions/YYYY-MM-DD-<slug>.md <slug> = your branch's last segment
|
||||
|
||||
with frontmatter (`date`, `branch`, `title`) and exactly these three sections:
|
||||
|
||||
## What changed
|
||||
## What I verified
|
||||
## What I did NOT verify
|
||||
|
||||
State gaps plainly — an honest "UNVERIFIED" is worth more than an optimistic "green",
|
||||
and this repo has already been burned by the difference. All three headings are
|
||||
**enforced** by `ci/decisions-log.sh`, because the third is the one that gets dropped.
|
||||
Template and full rationale: `decisions/README.md`.
|
||||
|
||||
**Do NOT append to `DECISIONS-LOG.md`.** It is a frozen archive (pre-2026-09-29), its
|
||||
SHA-256 is pinned in `decisions/ARCHIVE.sha256`, and the merge gate rejects any change
|
||||
to it. Appending to its tail is what every agent used to do, and because every branch
|
||||
appended to the *same last hunk*, no two concurrent branches could auto-merge. Measured
|
||||
on 2026-09-29: **four of the five open PRs were `mergeable: false`, with
|
||||
`DECISIONS-LOG.md` the only conflicting path in all four** — none of those changes had
|
||||
anything to do with each other. Separate files have nothing to merge, which is the
|
||||
entire fix. Note that the damage from getting this wrong lands on *other people's*
|
||||
branches, later, which is why it is a gate stage and not a request.
|
||||
|
||||
Many older handoff entries and runbooks say "append to `DECISIONS-LOG.md`". That
|
||||
wording is **stale**, in the same way §1's `git push origin main` wording is stale.
|
||||
@@ -1,3 +1,16 @@
|
||||
> **FROZEN ARCHIVE — 2026-08-16 … 2026-09-29. Do not append to this file.**
|
||||
>
|
||||
> New handoff entries go in **`decisions/`**, one file per entry
|
||||
> (`decisions/YYYY-MM-DD-<slug>.md`). See [`decisions/README.md`](decisions/README.md).
|
||||
>
|
||||
> Why: every agent appended to this file's tail, so any two concurrent branches
|
||||
> conflicted on the same last hunk. On 2026-09-29 that made four of the five open PRs
|
||||
> unmergeable, with this file as the *only* conflicting path in all four. Separate files
|
||||
> have nothing to merge.
|
||||
>
|
||||
> This file's SHA-256 is pinned in `decisions/ARCHIVE.sha256` and the merge gate
|
||||
> (`ci/decisions-log.sh`) rejects any change to it.
|
||||
|
||||
# Autonomous Session Decision Log (PM)
|
||||
|
||||
Program Manager working independently while the operator is AFK (started 2026-08-16). Every
|
||||
|
||||
+4
-1
@@ -12,7 +12,7 @@ CI_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
.PHONY: help spec-immutability lint schema l0 render-golden l1 conformance gate all touched versions tools test
|
||||
.PHONY: help spec-immutability decisions-log lint schema l0 render-golden l1 conformance gate all touched versions tools test
|
||||
|
||||
help: ## Show this help
|
||||
@awk 'BEGIN{FS=":.*##"} /^[a-zA-Z0-9_-]+:.*##/{printf " \033[36m%-14s\033[0m %s\n",$$1,$$2}' $(MAKEFILE_LIST)
|
||||
@@ -29,6 +29,9 @@ schema l0: ## ServiceDefinition schema validation + Kyverno L0 policy gate (no c
|
||||
render-golden: ## render==golden: canonical render reproduces committed apps/ (no cluster; live now)
|
||||
@"$(CI_DIR)render-golden.sh"
|
||||
|
||||
decisions-log: ## handoff log: DECISIONS-LOG.md frozen, decisions/ entries well-formed (no cluster)
|
||||
@"$(CI_DIR)decisions-log.sh"
|
||||
|
||||
l1: ## L1 rendering checks (no cluster; live now)
|
||||
@"$(CI_DIR)l1.sh"
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@ caller that must distinguish them invokes `./ci/gate.sh` directly.
|
||||
|---|---|---|
|
||||
| `lint`, `l0`, `render-golden`, `l1` | a **pinned tool is absent** or a **wired seam went unwired** — it *could not run* | **BLOCKED** under strict skips (a CI runner: the toolchain is installed for us by `.gitea/workflows/scripts/install-tools.sh`, so a skip is a defect); **UNDETERMINED** otherwise (a workstation missing a tool is a real local condition — still non-zero, still not a pass; the remedy is `infra/devcontainer/dev.sh`, which carries the pinned toolchain) |
|
||||
| `spec-immutability` (R-TEST-06) | the sibling spec repo is not checked out, so the spec **could not be verified** | **UNDETERMINED** — unverified is unverified. Deliberately *not* upgraded to BLOCKED by strict skips: the live runner does not check the spec repo out at all, so blocking would leave the only enforced status check permanently red for a reason no PR could clear. It is therefore the **one** skip whose non-answer a caller may knowingly tolerate, and `.gitea/workflows/gate.yml` does so in one visible line. `REQUIRE_SPEC_VERIFICATION=true` makes it a hard block once that checkout is wired. Locally: check the spec repo out beside this one, or set `SPEC_REPO` (`dev.sh` mounts a sibling automatically). |
|
||||
| `decisions-log` | **nothing** — this stage has no skip path. It reads only files in the tree under test and needs no tool and no cluster, so there is no environment in which it "could not run" | a reported skip is a **defect in the stage** and BLOCKS. The stage fails when the frozen `DECISIONS-LOG.md` is modified (which silently re-creates, for every *other* open branch, the tail-hunk conflict that made four of five PRs unmergeable on 2026-09-29) or when a `decisions/` entry is malformed. There is deliberately **no** `REQUIRE_DECISIONS_LOG` knob: a guard with an off switch is not a guard. See `decisions/README.md`. |
|
||||
| `conformance` (L2/L3) | `CLUSTER_AVAILABLE!=true` — the environment **declared** the capability absent (documented Phase-0 behaviour) | **does not deny a PASS**: a declared-inapplicable stage was never *supposed* to run. It is NAMED in the PASS headline and step summary, the touched requirements it would have covered are reported UNVERIFIED, and `REQUIRE_CLUSTER_TESTS=true` (Phase 1+) turns that into a block. A skip **despite** `CLUSTER_AVAILABLE=true` is a defect and BLOCKS. |
|
||||
|
||||
That asymmetry is the whole rule: **declared-not-applicable may pass; could-not-run may not.**
|
||||
|
||||
Executable
+195
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env bash
|
||||
# ci/decisions-log.sh — the handoff log is a DIRECTORY, and the old single file is frozen.
|
||||
#
|
||||
# WHY THIS STAGE EXISTS
|
||||
# ---------------------
|
||||
# CLAUDE.md §7 tells every agent to record a handoff before finishing. Until 2026-09-29 that
|
||||
# meant appending to the tail of one 5,228-line file, DECISIONS-LOG.md. Every branch appended
|
||||
# to the SAME last hunk, so git could not auto-merge any two of them. Measured that day against
|
||||
# the live Gitea: four of the five open PRs were `mergeable: false`, and `git merge-tree`
|
||||
# against `main` named DECISIONS-LOG.md as the ONLY conflicting path in all four:
|
||||
#
|
||||
# #22 fix/crafty-oauth2-proxy-signin CONFLICT (content): DECISIONS-LOG.md
|
||||
# #20 feat/conformance-job-template CONFLICT (content): DECISIONS-LOG.md
|
||||
# #19 feat/conformance-trigger-kubeconfig CONFLICT (content): DECISIONS-LOG.md
|
||||
# #18 feat/conformance-runner-image CONFLICT (content): DECISIONS-LOG.md
|
||||
# #17 feat/ci-is-the-approver clean — it wrote no handoff entry
|
||||
#
|
||||
# Nothing was wrong with any of those changes. The log's storage format serialised work that
|
||||
# was otherwise parallel, and the cost grew with every concurrent branch (N branches -> N-1
|
||||
# rebases, each invalidating the gate run before it).
|
||||
#
|
||||
# The fix is that handoffs now live one-per-file under decisions/, so concurrent branches touch
|
||||
# disjoint paths and there is nothing to merge. See decisions/README.md.
|
||||
#
|
||||
# WHY IT NEEDS A CHECK AND NOT JUST A DOCUMENT
|
||||
# --------------------------------------------
|
||||
# The old habit is written into this repo in many places — the runbooks, the muscle memory of
|
||||
# every agent that has read the log, and (until this change) CLAUDE.md itself. An agent that
|
||||
# appends to DECISIONS-LOG.md anyway does not fail: it produces a perfectly good-looking commit
|
||||
# that quietly re-creates the conflict for every other open branch, and the damage is only
|
||||
# visible later, to somebody else, in a PR that is not theirs. That is the exact shape this
|
||||
# repo keeps getting burned by — a silent regression with a delayed, misattributed symptom — so
|
||||
# it gets a guard rather than a paragraph.
|
||||
#
|
||||
# WHAT IS ASSERTED
|
||||
# (1) DECISIONS-LOG.md is byte-identical to the SHA-256 pinned in decisions/ARCHIVE.sha256.
|
||||
# Re-pinning is a deliberate, reviewable line in a diff; appending is not.
|
||||
# (2) Every decisions/ entry is named YYYY-MM-DD-<slug>.md with a real calendar date, so the
|
||||
# directory sorts chronologically and two agents cannot collide on a filename.
|
||||
# (3) Every entry carries frontmatter (date, branch, title) whose `date` agrees with its
|
||||
# filename — a disagreement means a copied template nobody edited.
|
||||
# (4) Every entry has all three of CLAUDE.md §7's sections with a real body under each:
|
||||
# "What changed", "What I verified", "What I did NOT verify". This is the quality floor
|
||||
# that stops an entry degrading into "done ✅". The third heading is the load-bearing one:
|
||||
# this repo's recurring failure is a confident report of something nobody measured, and an
|
||||
# entry with nowhere to write "UNVERIFIED" does not get one written.
|
||||
#
|
||||
# EXIT CODES (ci/README.md contract): 0 pass, 1 BLOCKS the merge. This stage has NO skip path —
|
||||
# it reads only files that are in the tree it is run against, needs no tool beyond coreutils and
|
||||
# no cluster, so there is no environment in which it "could not run". Adding an rc 3 here would
|
||||
# add a fail-open surface to a guard whose whole job is to close one.
|
||||
set -uo pipefail
|
||||
CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck disable=SC1091
|
||||
source "${CI_DIR}/lib.sh"
|
||||
set +e # aggregate every problem in one run; do not stop at the first.
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
ARCHIVE="DECISIONS-LOG.md"
|
||||
PIN="decisions/ARCHIVE.sha256"
|
||||
rc=0
|
||||
|
||||
# ---- (1) the archive is frozen ----------------------------------------------
|
||||
log "Stage: decisions log (archive frozen + entries well-formed)"
|
||||
|
||||
if [ ! -f "$ARCHIVE" ]; then
|
||||
err "${ARCHIVE} is missing. It is the frozen pre-2026-09-29 history and must stay in the tree."
|
||||
summary "- decisions-log: **FAILED** (\`${ARCHIVE}\` missing)"
|
||||
rc=1
|
||||
elif [ ! -f "$PIN" ]; then
|
||||
err "${PIN} is missing, so the archive's integrity cannot be established."
|
||||
err " If you are re-pinning deliberately: sha256sum ${ARCHIVE} > ${PIN}"
|
||||
summary "- decisions-log: **FAILED** (\`${PIN}\` missing — archive unverifiable)"
|
||||
rc=1
|
||||
else
|
||||
want="$(awk 'NR==1{print $1}' "$PIN")"
|
||||
got="$(sha256sum "$ARCHIVE" | awk '{print $1}')"
|
||||
if [ "$want" != "$got" ]; then
|
||||
err "${ARCHIVE} HAS CHANGED. It is a FROZEN ARCHIVE — do not append to it."
|
||||
err " pinned: ${want}"
|
||||
err " on disk: ${got}"
|
||||
err ""
|
||||
err " Every branch that appends to this file's tail conflicts with every other branch that"
|
||||
err " does, on the same hunk. That is what made four of five open PRs unmergeable on"
|
||||
err " 2026-09-29, and your commit would re-create it for branches that are not yours."
|
||||
err ""
|
||||
err " Write your handoff as its OWN FILE instead:"
|
||||
err " decisions/\$(date +%F)-<your-branch-slug>.md (see decisions/README.md)"
|
||||
err " and restore the archive: git checkout -- ${ARCHIVE}"
|
||||
err ""
|
||||
err " If the archive genuinely must change (redacting a leaked credential, say), that is a"
|
||||
err " reviewed act: re-pin it with 'sha256sum ${ARCHIVE} > ${PIN}' and say why in your entry."
|
||||
summary "- decisions-log: **FAILED — the frozen archive \`${ARCHIVE}\` was modified** (append to \`decisions/\` instead)"
|
||||
rc=1
|
||||
else
|
||||
ok "${ARCHIVE}: unchanged (sha256 matches ${PIN})"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---- (2)(3)(4) every entry is well-formed ------------------------------------
|
||||
shopt -s nullglob
|
||||
entries=( decisions/[0-9]*.md )
|
||||
shopt -u nullglob
|
||||
|
||||
# Zero entries is legitimate only until the first handoff lands; after that the glob always
|
||||
# matches. It is never an error here — a branch that changes no behaviour owes no handoff, and
|
||||
# CLAUDE.md, not this stage, is what asks for one.
|
||||
if [ "${#entries[@]}" -eq 0 ]; then
|
||||
log "no decisions/ entries yet"
|
||||
fi
|
||||
|
||||
REQUIRED_SECTIONS=("What changed" "What I verified" "What I did NOT verify")
|
||||
|
||||
for f in "${entries[@]}"; do
|
||||
base="${f#decisions/}"
|
||||
|
||||
# (2) filename
|
||||
if [[ ! "$base" =~ ^([0-9]{4}-[0-9]{2}-[0-9]{2})-([a-z0-9][a-z0-9-]*)\.md$ ]]; then
|
||||
err "${f}: name must be decisions/YYYY-MM-DD-<slug>.md with a lowercase [a-z0-9-] slug."
|
||||
err " Use your branch's last segment as the slug — it is already unique per unit of work."
|
||||
summary "- decisions-log: **FAILED** (\`${f}\` misnamed)"
|
||||
rc=1
|
||||
continue
|
||||
fi
|
||||
fname_date="${BASH_REMATCH[1]}"
|
||||
|
||||
if ! date -d "$fname_date" +%F >/dev/null 2>&1 || [ "$(date -d "$fname_date" +%F)" != "$fname_date" ]; then
|
||||
err "${f}: '${fname_date}' is not a real calendar date."
|
||||
summary "- decisions-log: **FAILED** (\`${f}\` — impossible date)"
|
||||
rc=1
|
||||
continue
|
||||
fi
|
||||
|
||||
# (3) frontmatter
|
||||
if [ "$(head -1 "$f")" != "---" ]; then
|
||||
err "${f}: must open with a '---' YAML frontmatter block (date, branch, title)."
|
||||
err " See decisions/README.md for the template."
|
||||
summary "- decisions-log: **FAILED** (\`${f}\` — no frontmatter)"
|
||||
rc=1
|
||||
continue
|
||||
fi
|
||||
fm="$(awk 'NR==1&&$0=="---"{inb=1;next} inb&&$0=="---"{exit} inb{print}' "$f")"
|
||||
missing_keys=()
|
||||
for k in date branch title; do
|
||||
grep -qE "^${k}:[[:space:]]*\S" <<<"$fm" || missing_keys+=("$k")
|
||||
done
|
||||
if [ "${#missing_keys[@]}" -gt 0 ]; then
|
||||
err "${f}: frontmatter is missing a non-empty ${missing_keys[*]}."
|
||||
summary "- decisions-log: **FAILED** (\`${f}\` — frontmatter missing \`${missing_keys[*]}\`)"
|
||||
rc=1
|
||||
continue
|
||||
fi
|
||||
fm_date="$(grep -m1 -E '^date:' <<<"$fm" | sed -E 's/^date:[[:space:]]*//; s/[[:space:]"'\'']//g')"
|
||||
if [ "$fm_date" != "$fname_date" ]; then
|
||||
err "${f}: frontmatter date '${fm_date}' disagrees with the filename's '${fname_date}'."
|
||||
err " One of them was copied from a template and not edited; the log's ordering depends on both."
|
||||
summary "- decisions-log: **FAILED** (\`${f}\` — date disagrees with filename)"
|
||||
rc=1
|
||||
continue
|
||||
fi
|
||||
|
||||
# (4) the three required sections, each with a real body
|
||||
body_start="$(awk 'NR>1 && $0=="---" {print NR; exit}' "$f")"
|
||||
[ -n "$body_start" ] || body_start=1
|
||||
for want_sec in "${REQUIRED_SECTIONS[@]}"; do
|
||||
hdr_line="$(awk -v s="$body_start" -v w="## ${want_sec}" 'NR>s && $0==w {print NR; exit}' "$f")"
|
||||
if [ -z "$hdr_line" ]; then
|
||||
err "${f}: missing the required section '## ${want_sec}'."
|
||||
err " CLAUDE.md §7 asks for what you changed, what you verified, and what you did NOT."
|
||||
err " All three are structure here, because the third is the one that gets dropped."
|
||||
summary "- decisions-log: **FAILED** (\`${f}\` — missing \`## ${want_sec}\`)"
|
||||
rc=1
|
||||
continue
|
||||
fi
|
||||
# A real body: at least 20 non-blank, non-heading characters before the next '## '.
|
||||
nchars="$(awk -v s="$hdr_line" 'NR>s { if ($0 ~ /^## /) exit; print }' "$f" \
|
||||
| tr -d '[:space:]' | wc -c)"
|
||||
if [ "$nchars" -lt 20 ]; then
|
||||
err "${f}: section '## ${want_sec}' is empty or a stub (${nchars} chars of content)."
|
||||
err " If nothing was verified, WRITE THAT — 'nothing was verified; the gate was not run'"
|
||||
err " is a valid and useful entry. A blank section is not."
|
||||
summary "- decisions-log: **FAILED** (\`${f}\` — \`## ${want_sec}\` is a stub)"
|
||||
rc=1
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
ok "decisions log: archive frozen, ${#entries[@]} entr$([ "${#entries[@]}" -eq 1 ] && echo y || echo ies) well-formed"
|
||||
summary "- decisions-log: **passed** (archive frozen; ${#entries[@]} entries well-formed)"
|
||||
else
|
||||
err "decisions log stage FAILED"
|
||||
fi
|
||||
exit "$rc"
|
||||
+17
@@ -108,6 +108,7 @@ summary "## Merge gate (R-TEST-03)"
|
||||
summary ""
|
||||
|
||||
run_stage spec-immutability "${CI_DIR}/spec-immutability.sh"
|
||||
run_stage decisions-log "${CI_DIR}/decisions-log.sh"
|
||||
run_stage lint "${CI_DIR}/lint.sh"
|
||||
run_stage l0 "${CI_DIR}/l0.sh"
|
||||
run_stage render-golden "${CI_DIR}/render-golden.sh"
|
||||
@@ -214,6 +215,22 @@ case "${OUTCOME[gitea-settings]}" in
|
||||
pass) ok "gitea-settings: green" ;;
|
||||
esac
|
||||
|
||||
# decisions-log: the handoff log's storage format. A FAIL means either the frozen archive
|
||||
# DECISIONS-LOG.md was modified — which re-creates, for every OTHER open branch, the tail-hunk
|
||||
# conflict that made four of five PRs unmergeable on 2026-09-29 — or a decisions/ entry is
|
||||
# malformed. Always blocks, and has no skip path by construction: the stage reads only files in
|
||||
# the tree under test and needs no tool or cluster, so there is no environment in which it
|
||||
# "could not run". A guard against a silent, delayed, misattributed regression must not itself
|
||||
# have a way to not-run.
|
||||
case "${OUTCOME[decisions-log]}" in
|
||||
fail) err "decisions-log: FAILED -> blocks merge (see decisions/README.md)"; BLOCK=1 ;;
|
||||
skip) err "decisions-log: reported SKIPPED, which this stage has no path to produce -> treat"
|
||||
err " as a defect in the stage itself and block."
|
||||
summary "- decisions-log: **SKIPPED** (impossible by construction) — blocks merge"
|
||||
BLOCK=1 ;;
|
||||
pass) ok "decisions-log: green" ;;
|
||||
esac
|
||||
|
||||
# L0/L1 stages: any failure blocks. A SKIP here means a pinned tool is missing or a seam is
|
||||
# unwired — NEVER green. In CI (strict) that is a defect and blocks; on a workstation it
|
||||
# degrades to UNDETERMINED, which is still non-zero and still not a pass. render-golden is a
|
||||
|
||||
@@ -42,9 +42,16 @@
|
||||
# skip like spec-immutability's (UNDETERMINED, exit 3) rather than a hard block, so
|
||||
# that an unwired credential cannot lock the repository.
|
||||
#
|
||||
# TO WIRE IT: create a Gitea repo secret GITEA_SETTINGS_TOKEN (a token that can read repo
|
||||
# TO WIRE IT: create a Gitea repo secret SETTINGS_API_TOKEN (a token that can read repo
|
||||
# settings) — infra/gitea/set-ci-token.sh does it. Until then this stage skips LOUDLY and
|
||||
# the drift it exists to catch is still undetected.
|
||||
#
|
||||
# NOT `GITEA_SETTINGS_TOKEN`. This comment named that until 2026-09-29 and it instructed the
|
||||
# reader to do something IMPOSSIBLE: Gitea reserves the `GITEA_` prefix for secret names and
|
||||
# rejects the PUT with HTTP 400 "invalid secret name". That is not a style rule — it is why the
|
||||
# detector sat inert for hours while a token existed and no secret did. The env var below still
|
||||
# accepts GITEA_SETTINGS_TOKEN, but only for a LOCAL run: it can never be the CI wiring, because
|
||||
# the secret that would feed it cannot be created at all.
|
||||
set -euo pipefail
|
||||
CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${CI_DIR}/.." && pwd)"
|
||||
|
||||
Executable
+130
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env bash
|
||||
# ci/tests/decisions-log.test.sh — proves ci/decisions-log.sh actually fails closed.
|
||||
#
|
||||
# The stage exists because appending to DECISIONS-LOG.md silently re-creates a merge conflict
|
||||
# for every other open branch. A guard against a silent regression is itself worth nothing
|
||||
# unless somebody has watched it reject the thing it claims to reject, so each case below
|
||||
# CONSTRUCTS the violation in a throwaway tree and asserts a non-zero exit — the same reasoning
|
||||
# as ci/tests/gate-decision.test.sh, one layer down.
|
||||
set -uo pipefail
|
||||
REAL_CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
PASS=0 FAIL=0
|
||||
ok() { printf ' ok %s\n' "$*"; PASS=$((PASS+1)); }
|
||||
bad() { printf ' FAIL %s\n' "$*"; FAIL=$((FAIL+1)); }
|
||||
|
||||
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
# A minimal well-formed tree: the frozen archive, its pin, and one valid entry.
|
||||
mk_tree() {
|
||||
local d="$1"; rm -rf "$d"; mkdir -p "$d/ci" "$d/decisions"
|
||||
cp "${REAL_CI_DIR}/decisions-log.sh" "${REAL_CI_DIR}/lib.sh" "${REAL_CI_DIR}/versions.env" "$d/ci/"
|
||||
printf 'frozen history\n' > "$d/DECISIONS-LOG.md"
|
||||
( cd "$d" && sha256sum DECISIONS-LOG.md > decisions/ARCHIVE.sha256 )
|
||||
mk_entry "$d/decisions/2026-09-29-a-good-entry.md" 2026-09-29
|
||||
# REPO_ROOT in ci/lib.sh is derived from git; give the tree a repo so it resolves here.
|
||||
( cd "$d" && git init -q . && git add -A && git -c user.email=t@t -c user.name=t commit -qm init )
|
||||
}
|
||||
|
||||
mk_entry() {
|
||||
cat > "$1" <<ENTRY
|
||||
---
|
||||
date: $2
|
||||
branch: chore/example
|
||||
title: An example entry
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
Enough prose here to clear the twenty-character body floor comfortably.
|
||||
|
||||
## What I verified
|
||||
|
||||
Enough prose here to clear the twenty-character body floor comfortably.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
Enough prose here to clear the twenty-character body floor comfortably.
|
||||
ENTRY
|
||||
}
|
||||
|
||||
# run <dir> -> sets RC and OUT
|
||||
run() { OUT="$( cd "$1" && bash ci/decisions-log.sh 2>&1 )"; RC=$?; }
|
||||
|
||||
D="${TMP}/t"
|
||||
|
||||
echo "== 1. a well-formed tree passes"
|
||||
mk_tree "$D"; run "$D"
|
||||
[ "$RC" -eq 0 ] && ok "clean tree -> exit 0" || bad "clean tree -> exit ${RC}: ${OUT}"
|
||||
|
||||
echo
|
||||
echo "== 2. THE REGRESSION THIS STAGE EXISTS FOR: appending to the frozen archive"
|
||||
mk_tree "$D"; printf '\n## my new handoff\n' >> "$D/DECISIONS-LOG.md"; run "$D"
|
||||
[ "$RC" -ne 0 ] && ok "append to DECISIONS-LOG.md -> non-zero (${RC})" \
|
||||
|| bad "append to DECISIONS-LOG.md was ACCEPTED — the guard is inert"
|
||||
grep -qi 'frozen archive' <<<"$OUT" && ok "the message names the archive as frozen" \
|
||||
|| bad "the message does not say the archive is frozen"
|
||||
grep -q 'decisions/\$(date +%F)' <<<"$OUT" && ok "the message names the file to write instead" \
|
||||
|| bad "the message does not name the remedy"
|
||||
|
||||
echo
|
||||
echo "== 3. a re-pin is a deliberate, accepted act"
|
||||
mk_tree "$D"; printf '\nredacted\n' >> "$D/DECISIONS-LOG.md"
|
||||
( cd "$D" && sha256sum DECISIONS-LOG.md > decisions/ARCHIVE.sha256 ); run "$D"
|
||||
[ "$RC" -eq 0 ] && ok "archive changed AND re-pinned -> exit 0" || bad "re-pin rejected: ${OUT}"
|
||||
|
||||
echo
|
||||
echo "== 4. a missing pin is unverifiable, not green"
|
||||
mk_tree "$D"; rm "$D/decisions/ARCHIVE.sha256"; run "$D"
|
||||
[ "$RC" -ne 0 ] && ok "no ARCHIVE.sha256 -> non-zero (${RC})" || bad "missing pin was accepted"
|
||||
|
||||
echo
|
||||
echo "== 5. entry naming"
|
||||
for badname in 2026-9-29-x.md 26-09-29-x.md 2026-09-29-Upper.md 2026-09-29-.md 2026-02-30-x.md; do
|
||||
mk_tree "$D"; mk_entry "$D/decisions/${badname}" 2026-09-29; run "$D"
|
||||
[ "$RC" -ne 0 ] && ok "rejects decisions/${badname}" || bad "ACCEPTED decisions/${badname}"
|
||||
done
|
||||
mk_tree "$D"; mk_entry "$D/decisions/2026-10-01-fix-thing-2.md" 2026-10-01; run "$D"
|
||||
[ "$RC" -eq 0 ] && ok "accepts a digits-and-dashes slug" || bad "rejected a valid slug: ${OUT}"
|
||||
|
||||
echo
|
||||
echo "== 6. frontmatter"
|
||||
mk_tree "$D"; sed -i '1d' "$D/decisions/2026-09-29-a-good-entry.md"; run "$D"
|
||||
[ "$RC" -ne 0 ] && ok "rejects an entry with no frontmatter" || bad "accepted a bare entry"
|
||||
|
||||
for key in date branch title; do
|
||||
mk_tree "$D"; sed -i "/^${key}:/d" "$D/decisions/2026-09-29-a-good-entry.md"; run "$D"
|
||||
[ "$RC" -ne 0 ] && ok "rejects frontmatter missing '${key}'" || bad "accepted missing '${key}'"
|
||||
done
|
||||
|
||||
# The copied-template failure: the body was rewritten, the date never was.
|
||||
mk_tree "$D"; mk_entry "$D/decisions/2026-09-30-copied.md" 2026-09-29; run "$D"
|
||||
[ "$RC" -ne 0 ] && ok "rejects a frontmatter date that disagrees with the filename" \
|
||||
|| bad "accepted a date/filename disagreement"
|
||||
|
||||
echo
|
||||
echo "== 7. the three required sections, and their bodies"
|
||||
for sec in 'What changed' 'What I verified' 'What I did NOT verify'; do
|
||||
mk_tree "$D"; sed -i "/^## ${sec}\$/d" "$D/decisions/2026-09-29-a-good-entry.md"; run "$D"
|
||||
[ "$RC" -ne 0 ] && ok "rejects an entry missing '## ${sec}'" || bad "accepted missing '## ${sec}'"
|
||||
done
|
||||
|
||||
# A heading with nothing under it is the way this floor gets defeated in practice.
|
||||
mk_tree "$D"
|
||||
python3 - "$D/decisions/2026-09-29-a-good-entry.md" <<'PY'
|
||||
import sys,re
|
||||
p=sys.argv[1]; s=open(p).read()
|
||||
s=re.sub(r'## What I did NOT verify\n\n.*', '## What I did NOT verify\n\nn/a\n', s, flags=re.S)
|
||||
open(p,'w').write(s)
|
||||
PY
|
||||
run "$D"
|
||||
[ "$RC" -ne 0 ] && ok "rejects a stub body under a required section" || bad "accepted an 'n/a' section"
|
||||
|
||||
echo
|
||||
echo "== 8. README.md in decisions/ is not an entry and is not policed as one"
|
||||
mk_tree "$D"; printf 'not an entry\n' > "$D/decisions/README.md"; run "$D"
|
||||
[ "$RC" -eq 0 ] && ok "decisions/README.md ignored by the entry rules" || bad "README policed: ${OUT}"
|
||||
|
||||
echo
|
||||
if [ "$FAIL" -eq 0 ]; then echo "decisions-log tests: PASS ($PASS assertions)"; exit 0
|
||||
else echo "decisions-log tests: FAIL ($FAIL failed, $PASS passed)"; exit 1; fi
|
||||
@@ -36,7 +36,13 @@ REPO="${TMP}/repo"; mkdir -p "$REPO"
|
||||
cp "${REAL_CI_DIR}/gate.sh" "${REAL_CI_DIR}/lib.sh" "${REAL_CI_DIR}/versions.env" "$CI/"
|
||||
|
||||
# One stub per stage. It exits with STAGE_RC_<stage-with-dashes-as-underscores>, default 0.
|
||||
for stage in spec-immutability lint l0 render-golden l1 conformance; do
|
||||
# NOTE: this list must name EVERY stage gate.sh runs. It did not: `gitea-settings` was added to
|
||||
# gate.sh and never added here, so its script was absent from the stub CI dir, `run_stage` got
|
||||
# exit 127 from the missing file, and the gate recorded a FAILED stage in every single case —
|
||||
# 15 of 47 assertions failed on `main` for that reason alone, including every one that expects a
|
||||
# PASS/UNDETERMINED/NOT-APPLICABLE headline. The suite was measuring the missing stub, not the
|
||||
# decision logic. `decisions-log` is added here in the same change that adds it to gate.sh.
|
||||
for stage in spec-immutability decisions-log lint l0 render-golden l1 gitea-settings conformance; do
|
||||
cat > "${CI}/${stage}.sh" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
n="$(basename "$0" .sh)"; v="STAGE_RC_${n//-/_}"
|
||||
@@ -184,6 +190,21 @@ assert_verdict BLOCKED 1 "l0 FAILED + l1 SKIPPED"
|
||||
run_gate STAGE_RC_spec_immutability=3 STAGE_RC_conformance=3
|
||||
assert_verdict UNDETERMINED 3 "spec-immutability SKIPPED + conformance declared-absent"
|
||||
|
||||
echo
|
||||
echo "== 10. decisions-log: the handoff log's storage format has no non-blocking outcome"
|
||||
# A FAIL here means the frozen archive was appended to (re-creating, for every OTHER open
|
||||
# branch, the tail-hunk conflict that made 4 of 5 PRs unmergeable on 2026-09-29) or an entry is
|
||||
# malformed. A SKIP is impossible by construction — the stage needs no tool and no cluster — so
|
||||
# if one is ever reported it is a defect in the stage, and the gate must not soften it into
|
||||
# UNDETERMINED. Both outcomes block, and neither is upgradeable by an env knob: there is
|
||||
# deliberately no REQUIRE_DECISIONS_LOG, because a guard with an off switch is not a guard.
|
||||
run_gate STAGE_RC_decisions_log=1
|
||||
assert_verdict BLOCKED 1 "decisions-log FAILED (archive modified / entry malformed)"
|
||||
run_gate STAGE_RC_decisions_log=3
|
||||
assert_verdict BLOCKED 1 "decisions-log SKIPPED (impossible by construction -> defect, blocks)"
|
||||
run_gate STAGE_RC_decisions_log=1 STAGE_RC_l1=3
|
||||
assert_verdict BLOCKED 1 "decisions-log FAILED outranks an l1 non-run"
|
||||
|
||||
echo
|
||||
if [ "$FAIL" -eq 0 ]; then echo "gate-decision tests: PASS ($PASS assertions)"; exit 0
|
||||
else echo "gate-decision tests: FAIL ($FAIL failed, $PASS passed)"; exit 1; fi
|
||||
@@ -0,0 +1,123 @@
|
||||
---
|
||||
date: 2026-09-29
|
||||
branch: fix/crafty-oauth2-proxy-signin
|
||||
title: Crafty "won't sign in after Keycloak" — the gate was fine, Crafty's own login was the wall
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
Reported as *"crafty utilizing the keycloak oauth2-proxy does not sign in to the service after
|
||||
signing into keycloak; there is no way to set the service up without credentials."* The sidecar's
|
||||
own access log settles which layer refused, and it is not the one the report names:
|
||||
|
||||
GET / -> 302 to Keycloak
|
||||
[AuthSuccess] ... groups:[platform-admins] <- oauth2-proxy did its job
|
||||
GET /oauth2/callback -> 302
|
||||
GET /panel/dashboard -> 302
|
||||
GET /login?next=/panel/dashboard -> 200 <- CRAFTY's own login page
|
||||
POST /api/v2/auth/login/ -> 401 <- CRAFTY refused
|
||||
|
||||
**The Keycloak gate was never broken.** Crafty 4 has no OIDC and cannot consume the sidecar's
|
||||
identity headers, so it presents its own local login to an already-authenticated user. That second
|
||||
factor is intended. What was not intended — and what made the service genuinely impossible to set
|
||||
up — is that **nothing documented where that password comes from**. Crafty generates it **once, on
|
||||
first boot only** (users table empty → `DatabaseBuilder.default_settings()`) into
|
||||
`/crafty/app/config/default-creds.txt`, mode `0600`, on the PVC's `config` subPath. Never in git; a
|
||||
fresh PVC generates a new one and the old is gone with the volume. `platform/crafty/README.md`
|
||||
asserted the double login was "expected, not a bug" and then left the reader with no way forward —
|
||||
the assertion was true and useless in the same sentence.
|
||||
|
||||
So this branch:
|
||||
|
||||
- Adds `runbooks/crafty-admin-credential.md` — tell the two auth layers apart from the sidecar log,
|
||||
retrieve and rotate the bootstrap credential, and reset a lost one via argon2. Registered in the
|
||||
runbooks `configMapGenerator`/`kustomization.yaml` and listed in `runbooks/README.md`, so the URL
|
||||
actually resolves rather than the file merely existing.
|
||||
- Documents the credential's provenance and the two auth layers in `platform/crafty/README.md`.
|
||||
- **Fixes a second, real defect found while proving the first.** Both oauth2-proxy instances emitted
|
||||
oauth2-proxy's **default** cookie names (`_oauth2_proxy`, `_oauth2_proxy_csrf`) scoped to
|
||||
`.home.keithjackson.dev`, while holding **different** cookie-secrets. One cookie pair, two
|
||||
proxies: whichever authenticated last clobbered the other's session; the loser logged `No valid
|
||||
authentication in request`, bounced to Keycloak, and — SSO session still live — was redirected
|
||||
straight back, clobbering it again. **That mutual eviction presents exactly as the reported
|
||||
symptom**, intermittently, and would have been blamed on Keycloak indefinitely. Fixed on the
|
||||
crafty side only (renaming the shared proxy's cookie would sign out every other gated app): a
|
||||
unique `--cookie-name=_oauth2_proxy_crafty` plus a **host-scoped** `--cookie-domain`.
|
||||
`--whitelist-domain` stays zone-wide — it governs post-login *redirect* targets, not cookie scope.
|
||||
|
||||
**Considered and rejected: seeding `default.json` from a SOPS Secret.** Crafty does ship the hook —
|
||||
`find_default_password()` reads `app/config/default.json` (`username`, `password`, min 8 chars) and
|
||||
`delete_default_json` controls its removal — so an initContainer could make a rebuilt Crafty's admin
|
||||
password deterministic and git-managed. Rejected: the image entrypoint pulls its default config
|
||||
**only when `app/config` is empty** (`if [ ! "$(ls -A --ignore=.gitkeep ./app/config)" ]; then cp -r
|
||||
./app/config_original/* ./app/config/`). Writing `default.json` there pre-boot makes the directory
|
||||
non-empty and **suppresses the entire config bootstrap** — no `config.json`, no `credits.json`.
|
||||
Doing it safely means replicating the entrypoint's copy inside the initContainer, i.e. co-owning
|
||||
image-internal bootstrap logic that a 4.11 bump can change silently, to replace a one-command
|
||||
retrieval. Not worth it. If it is ever revisited, the `ls -A` guard is the landmine to design around.
|
||||
|
||||
## What I verified
|
||||
|
||||
- **Which layer refused**, from the sidecar's own access log: `[AuthSuccess] ...
|
||||
groups:[platform-admins]` followed by Crafty's `/login` page and `POST /api/v2/auth/login/ → 401`.
|
||||
oauth2-proxy admitted the user; Crafty rejected the local credential.
|
||||
- **The existing bootstrap credential is still usable**, so no DB surgery is needed to get in:
|
||||
`users.last_update` **equals** `users.created` for `admin` (`09/05/2026, 08:10:04`) — the password
|
||||
has never been changed — and admin's `last_login` is 09/14 from `127.0.0.1`, the API login that
|
||||
created the "homelab" server. `default-creds.txt` is therefore live.
|
||||
- **The two cookie-secrets differ** — compared the two live Secrets (key presence/values compared
|
||||
in-cluster, not printed), so neither proxy can decrypt the other's cookie; and both instances were
|
||||
emitting the default cookie names on the shared `.home.keithjackson.dev` scope.
|
||||
- **The 3-strikes cooldown cannot fire behind this sidecar.** Crafty *records* failed attempts under
|
||||
`get_remote_ip()` (honours `X-Forwarded-For` — the live tracker key is the chain
|
||||
`"192.168.2.132, 10.0.0.123"`, with 1 attempt: the reported 401) but *checks* the cooldown under
|
||||
`self.request.remote_ip`, always `127.0.0.1` here. The halves key differently, so
|
||||
`get_recent_attempts()` always reads empty and `max_login_attempts: 3` never fires. Good
|
||||
(**repeated guesses cannot lock the operator out**) and bad (that throttle is not a control we
|
||||
have; the `platform-admins` gate is the only thing limiting who reaches the form). I had written
|
||||
the opposite into the README from the config value alone before measuring it — the config key
|
||||
existing is not the same claim as the code path running.
|
||||
- **`set_passwd` is unreachable in k8s**: it calls `getpass.getpass()` and needs a TTY; the container
|
||||
runs `main.py -d -i`. Recovery must go through the DB.
|
||||
- **argon2 is installed only in `/crafty/.venv/bin/python`** — the system `python3` in that container
|
||||
cannot import it, so the obvious reset one-liner fails.
|
||||
- **`dev.sh` does not forward stdin.** A `kubectl exec -i` through it hangs forever (provoked, not
|
||||
assumed). So the runbook's reset generates its password **in-pod** into a `0600` file — Crafty's own
|
||||
`default-creds.txt` idiom — rather than passing a secret through argv, stdin or shell history.
|
||||
- **Gate:** toolbox `./ci/gate.sh` — see the next section for the exit code. Green stages:
|
||||
`spec-immutability`, `lint`, `l0`, `render-golden`, `l1`.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **No browser round-trip.** The sign-in fix is **UNVERIFIED end-to-end**. The cookie change is
|
||||
reasoned from measured facts (default cookie names on both, shared domain, differing secrets) and
|
||||
renders correctly, but nobody has signed in through it. The human test is in
|
||||
`platform/crafty/README.md`.
|
||||
- **Nothing was applied.** No `kubectl apply/edit/patch`, no `flux reconcile`. The running pod is
|
||||
still the pre-fix spec (`crafty-64f7b6d78-pkpgp`, 2/2, 15d, 0 restarts — unchanged by this
|
||||
session's read-only probes). **Merging is what deploys, and it will restart the pod** —
|
||||
`strategy: Recreate` on an RWO volume, so the "homelab" Minecraft server stops and restarts. Pick
|
||||
the moment.
|
||||
- **Gate rc 3, not 0.** Green: `spec-immutability`, `lint`, `l0`, `render-golden`, `l1`. **Skipped:**
|
||||
`gitea-settings` (no `SETTINGS_API_TOKEN`) and **L2/L3 conformance** (`CLUSTER_AVAILABLE` unset) —
|
||||
touched requirements `R-DNS-01 R-NET-01 R-OBS-04 R-PLAT-02 R-STATE-01` are **not certified**.
|
||||
Nothing failed; nothing runtime was proven either. **rc 3 is UNDETERMINED, not green.**
|
||||
- **The admin password was not rotated**, and the bootstrap credential is still the standing admin
|
||||
credential. The runbook says to rotate on first sign-in; that is an operator action and is still
|
||||
open.
|
||||
- **The cookie collision's symptom was never reproduced in a browser.** This is the part of the
|
||||
change with no observed failure to point at — the sidecar trace above shows a *working* session,
|
||||
because that browser had not visited another gated app first. It is a latent-bug fix.
|
||||
|
||||
### Follow-ups
|
||||
|
||||
1. **Sign in and rotate** — `runbooks/crafty-admin-credential.md`, then confirm `last_update` moves
|
||||
off `created`. Until then the panel's admin password is a value generated on 2026-09-05 that has
|
||||
sat unchanged and undocumented.
|
||||
2. **Decide whether the inert brute-force cooldown matters.** Fixing it is upstream (Crafty keys the
|
||||
two halves differently); the alternative is to accept the `platform-admins` gate as the only
|
||||
throttle and say so.
|
||||
3. **Any further per-app reverse-proxy sidecar needs a unique `--cookie-name` too.** The collision is
|
||||
a property of the pattern, not of Crafty. Worth an L0 check asserting that every oauth2-proxy
|
||||
instance in the tree declares a distinct cookie name — the failure is silent and intermittent,
|
||||
which is exactly the shape this repo keeps getting burned by.
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
date: 2026-09-29
|
||||
branch: chore/decisions-log-conflict-free
|
||||
title: The handoff log was serialising every parallel branch — it is a directory now
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
`DECISIONS-LOG.md` is frozen and the handoff log is now a directory, `decisions/`, with one
|
||||
file per entry (`YYYY-MM-DD-<slug>.md`). Concurrent branches write disjoint paths, so there
|
||||
is nothing for git to merge.
|
||||
|
||||
The problem was not any particular change. CLAUDE.md §7 told every agent to append a handoff,
|
||||
and appending always landed on the *same last hunk* of one 5,228-line file. Two branches that
|
||||
both did it could not auto-merge; N branches cost N-1 rebases, and each rebase invalidated the
|
||||
gate run before it. The agents work in isolated worktrees precisely so they don't block each
|
||||
other, and this one file undid that.
|
||||
|
||||
- `decisions/README.md` — the convention, the template, and how to read the log. No index file
|
||||
and no sequence number, deliberately: an index is a shared mutable file (the thing that just
|
||||
failed) and a counter has two agents racing for the same number. The directory listing is the
|
||||
index.
|
||||
- `DECISIONS-LOG.md` — a "FROZEN ARCHIVE" header prepended; content otherwise untouched. Its
|
||||
SHA-256 is pinned in `decisions/ARCHIVE.sha256`.
|
||||
- `ci/decisions-log.sh` — a new gate stage. Asserts the archive matches its pin, and that every
|
||||
entry is named `YYYY-MM-DD-<slug>.md` with a real date, carries `date`/`branch`/`title`
|
||||
frontmatter agreeing with its filename, and has all three of §7's sections with a real body
|
||||
under each. Exit 0 or 1 only; it has no skip path by construction, because it reads only files
|
||||
in the tree under test and needs no tool and no cluster.
|
||||
- `ci/gate.sh` — runs the stage; both `fail` and (impossible) `skip` set `BLOCK`. There is no
|
||||
`REQUIRE_DECISIONS_LOG` knob: a guard with an off switch is not a guard.
|
||||
- `ci/tests/decisions-log.test.sh` — 22 assertions, new.
|
||||
- `ci/tests/gate-decision.test.sh` — 3 new assertions for the stage's wiring, **plus a
|
||||
pre-existing bug fixed**: its stub list never included `gitea-settings`, so that stage's
|
||||
script was absent from the stub CI dir, `run_stage` recorded exit 127 as a FAILED stage in
|
||||
every case, and 15 of 47 assertions failed on `main`. The suite was measuring its own missing
|
||||
stub rather than the decision logic.
|
||||
- `CLAUDE.md` §7 rewritten; §1's "stale runbook wording" caveat extended to the log.
|
||||
- `ci/Makefile`, `ci/README.md`, `.gitea/workflows/README.md` — the stage named where stages are
|
||||
named.
|
||||
|
||||
A guard rather than a paragraph, because the old habit is written into the runbooks and into
|
||||
every agent that has read the log, and getting it wrong does not fail: it produces a clean-looking
|
||||
commit that re-creates the conflict on *other people's* branches, later, misattributed.
|
||||
|
||||
## What I verified
|
||||
|
||||
Measured against the live Gitea (`GET /api/v1/repos/Home-Network/home-network-k3s-impl/pulls`,
|
||||
2026-09-29), not inferred from the tree:
|
||||
|
||||
#22 fix/crafty-oauth2-proxy-signin mergeable=False
|
||||
#20 feat/conformance-job-template mergeable=False
|
||||
#19 feat/conformance-trigger-kubeconfig mergeable=False
|
||||
#18 feat/conformance-runner-image mergeable=False
|
||||
#17 feat/ci-is-the-approver mergeable=True
|
||||
|
||||
`git merge-tree --write-tree origin/main origin/<branch>` for each named
|
||||
`CONFLICT (content): DECISIONS-LOG.md` and **no other conflicting path** in all four. #17 is
|
||||
clean and touched no log entry. So the log's storage format was the whole of the blockage.
|
||||
|
||||
Tests: `ci/tests/decisions-log.test.sh` **PASS (22 assertions)**, including the one that
|
||||
matters — an append to `DECISIONS-LOG.md` exits non-zero and the message names both the frozen
|
||||
archive and the file to write instead. `ci/tests/gate-decision.test.sh` **PASS (50 assertions)**,
|
||||
up from **FAIL (15 failed, 32 passed)** on `main` before the stub fix.
|
||||
|
||||
Gate: see the run quoted in the PR description.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **Nothing was applied and nothing was merged.** No `kubectl`, no `flux reconcile`. This change
|
||||
touches only CI and documentation; it has no cluster-visible effect at all.
|
||||
- **L2/L3 conformance did not run** (`CLUSTER_AVAILABLE` unset), as always. Irrelevant to this
|
||||
change's content but stated so the gate result is not over-read.
|
||||
- **I have not watched a Gitea PR check run this stage.** The stage is proven by its own unit
|
||||
test and by the gate locally; that the `incluster-dind` runner executes it is unobserved here.
|
||||
A skipped Gitea job still reports `success`, so read the job log, not the tick.
|
||||
- **The four hung PRs are not yet fixed by this commit.** They still carry their
|
||||
`DECISIONS-LOG.md` appends and will still conflict until each is rebased onto this change with
|
||||
its append converted to a `decisions/` entry. That is follow-up work, in flight separately.
|
||||
- **`runbooks/` and older entries still say "append to DECISIONS-LOG.md"** in places. I did not
|
||||
sweep them. The gate now rejects the action, so the stale wording misleads but cannot cause
|
||||
the damage; it joins the existing tracked work on stale `git push origin main` wording.
|
||||
@@ -0,0 +1 @@
|
||||
b16e8cb7ea12720bc7c3a77a496211e3204534c5e6600ba748fb9ab14fa21c26 DECISIONS-LOG.md
|
||||
@@ -0,0 +1,101 @@
|
||||
# `decisions/` — the handoff log, one file per entry
|
||||
|
||||
This directory **is** the decision log. `../DECISIONS-LOG.md` is the frozen archive of
|
||||
everything written before 2026-09-29 and is never edited again (see "The archive is
|
||||
frozen" below).
|
||||
|
||||
## Why this exists
|
||||
|
||||
`DECISIONS-LOG.md` was append-only in a single file, and every agent appended to its
|
||||
**tail**. Two branches that both append hit the same last hunk, so git cannot
|
||||
auto-merge them: the second one to be reviewed conflicts, and so does the third, and
|
||||
the fourth. Measured on 2026-09-29: **four of the five open PRs were unmergeable, and
|
||||
`DECISIONS-LOG.md` was the only conflicting file in all four**:
|
||||
|
||||
#22 fix/crafty-oauth2-proxy-signin CONFLICT (content): DECISIONS-LOG.md
|
||||
#20 feat/conformance-job-template CONFLICT (content): DECISIONS-LOG.md
|
||||
#19 feat/conformance-trigger-kubeconfig CONFLICT (content): DECISIONS-LOG.md
|
||||
#18 feat/conformance-runner-image CONFLICT (content): DECISIONS-LOG.md
|
||||
#17 feat/ci-is-the-approver clean (it touched no log entry)
|
||||
|
||||
Nothing was wrong with any of those changes. The log's *storage format* serialised work
|
||||
that was otherwise parallel, and it got worse with every concurrent branch: N branches
|
||||
produce N-1 rebases, each of which invalidates any gate run that preceded it.
|
||||
|
||||
The fix is the obvious one for concurrent appenders: **stop sharing a file**. Each
|
||||
handoff is its own file. Two agents writing handoffs touch disjoint paths, so there is
|
||||
nothing for git to merge and nothing to conflict. This scales to any number of parallel
|
||||
branches with zero coordination — which is the point, since the agents are deliberately
|
||||
isolated from each other in separate worktrees and cannot coordinate.
|
||||
|
||||
There is deliberately **no index file** and no counter. An index would be a shared
|
||||
mutable file — exactly the thing that just failed — and a sequence number would have
|
||||
two agents racing for `0043`. The directory listing is the index.
|
||||
|
||||
## Writing an entry
|
||||
|
||||
One file per handoff, named:
|
||||
|
||||
decisions/YYYY-MM-DD-<slug>.md
|
||||
|
||||
`<slug>` is lowercase `[a-z0-9-]`. **Use your branch name's last segment** — it is
|
||||
already unique per unit of work, which is precisely the property the filename needs.
|
||||
A branch `fix/crafty-oauth2-proxy-signin` handed off on 2026-09-29 writes:
|
||||
|
||||
decisions/2026-09-29-crafty-oauth2-proxy-signin.md
|
||||
|
||||
The file is YAML frontmatter followed by three required sections:
|
||||
|
||||
```markdown
|
||||
---
|
||||
date: 2026-09-29
|
||||
branch: fix/crafty-oauth2-proxy-signin
|
||||
title: The Keycloak gate works — Crafty's own login was the wall
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
Prose. What you actually did, and why.
|
||||
|
||||
## What I verified
|
||||
|
||||
The measurements, with their numbers. Quote the field you read, not the prose you
|
||||
believed. Name the gate result and its exit code.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
Everything else. An honest "UNVERIFIED" is worth more than an optimistic "green" —
|
||||
this repo has been burned by the difference more than once.
|
||||
```
|
||||
|
||||
All three headings are **required and enforced** (`ci/decisions-log.sh`). They are
|
||||
CLAUDE.md §7's contract; making them a checked structure is what stops an entry
|
||||
degrading into "done ✅".
|
||||
|
||||
## Reading the log
|
||||
|
||||
The whole log, oldest first:
|
||||
|
||||
ls decisions/[0-9]*.md | sort
|
||||
|
||||
The current PM handoff — the newest entry. Ties within a day are broken by what landed
|
||||
on `main` last, which is the honest answer, so ask git rather than the filename:
|
||||
|
||||
git log --diff-filter=A --format='%H' --name-only -- 'decisions/[0-9]*.md' \
|
||||
| grep '^decisions/' | head -1
|
||||
|
||||
For a quick skim of who said what:
|
||||
|
||||
grep -H '^title:' decisions/[0-9]*.md | sort
|
||||
|
||||
## The archive is frozen
|
||||
|
||||
`DECISIONS-LOG.md` is **read-only history**. Its SHA-256 is pinned in
|
||||
`decisions/ARCHIVE.sha256` and `ci/decisions-log.sh` fails the merge gate if the file
|
||||
changes. That is not ceremony: without it, the next agent to read `runbooks/` or an old
|
||||
CLAUDE.md habit appends to the tail again and re-creates the conflict for everybody
|
||||
else, silently, one PR at a time.
|
||||
|
||||
If the archive genuinely must change (a leaked credential to redact, say), that is a
|
||||
deliberate, reviewed act: change it, run `sha256sum DECISIONS-LOG.md > decisions/ARCHIVE.sha256`,
|
||||
and say in your handoff entry why. Re-pinning is visible in the diff. Appending is not.
|
||||
@@ -49,9 +49,49 @@ Crafty's UI is gated by a **dedicated oauth2-proxy sidecar in the Crafty pod** (
|
||||
`crafty.home.keithjackson.dev`). The `crafty` Service targets the sidecar, never Crafty's `:8443`, so
|
||||
the gate is **in the data path** (fail-closed: no valid `platform-admins` session ⇒ no upstream).
|
||||
|
||||
> **Note:** Crafty ALSO has its own local admin login (it has no OIDC). After oauth2-proxy admits the
|
||||
> user, they still see Crafty's own login page — a second, defense-in-depth factor. The operator logs
|
||||
> in with Crafty's local admin credentials (set on first boot). This is expected, not a bug.
|
||||
#### Cookie isolation (why the session used to vanish)
|
||||
|
||||
This sidecar and the **shared** forward-auth oauth2-proxy are independent instances with **different
|
||||
`cookie-secret` values** (measured in-cluster — neither can decrypt the other's cookie). Both used to
|
||||
emit oauth2-proxy's *default* cookie names (`_oauth2_proxy`, `_oauth2_proxy_csrf`) scoped to
|
||||
`.home.keithjackson.dev`, so the browser held **one** cookie pair for **both**: whichever proxy
|
||||
authenticated last clobbered the other's session, and the loser logged
|
||||
`No valid authentication in request`, bounced to Keycloak, and — because the Keycloak SSO session was
|
||||
still live — was redirected straight back, clobbering the cookie again. That mutual eviction presents
|
||||
exactly as *"I signed in to Keycloak but I'm still not signed in."*
|
||||
|
||||
Fixed on this side only (renaming the shared proxy's cookie would sign out every other gated app):
|
||||
`--cookie-name=_oauth2_proxy_crafty` and a **host-scoped** `--cookie-domain=crafty.home.keithjackson.dev`.
|
||||
`--whitelist-domain` stays zone-wide — it governs post-login *redirect* targets, not cookie scope.
|
||||
|
||||
#### Crafty's own local login — this is where setup actually blocks
|
||||
|
||||
Crafty has **no OIDC**, so after oauth2-proxy admits the user they still meet Crafty's own
|
||||
username/password page. That second factor is intended. What was **not** intended — and what made the
|
||||
service impossible to set up — is that **nothing documented where that password comes from**.
|
||||
|
||||
Crafty generates it **once, on first boot only**, when the users table is empty
|
||||
(`main.py` `FRESH_INSTALL` → `DatabaseBuilder.default_settings()`), and writes it to
|
||||
**`/crafty/app/config/default-creds.txt`** (mode `0600`, on the `config` subPath of the PVC). It is
|
||||
**never** in git and **cannot** be recovered from git: a rebuild onto a fresh PVC generates a *new*
|
||||
random password. Submitting anything else yields `POST /api/v2/auth/login/` → **401**, which is exactly
|
||||
what a browser sees with no hint as to why.
|
||||
|
||||
**→ Retrieval, rotation, and lost-password recovery: [`../../runbooks/crafty-admin-credential.md`](../../runbooks/crafty-admin-credential.md).**
|
||||
|
||||
Two traps that runbook covers:
|
||||
|
||||
- Crafty's `set_passwd` console command is **unreachable** in Kubernetes — it calls
|
||||
`getpass.getpass()` and so needs a TTY, while the container runs `main.py -d -i` (daemon).
|
||||
Recovery therefore has to go through the DB with Crafty's own argon2 hasher.
|
||||
- Crafty's own 3-strikes brute-force cooldown is **inert behind this sidecar**, verified live. Crafty
|
||||
*records* failed attempts under `get_remote_ip()` (which honours `X-Forwarded-For` — the live
|
||||
tracker key is the chain `"192.168.2.132, 10.0.0.123"`) but *checks* for a cooldown under
|
||||
`self.request.remote_ip`, which behind the sidecar is always `127.0.0.1`. The two halves key
|
||||
differently, so `get_recent_attempts()` always reads an empty list and `max_login_attempts: 3`
|
||||
never fires. Good news for the operator (**repeated guesses cannot lock you out**), but it means
|
||||
the only thing actually limiting who can reach the login form is the oauth2-proxy
|
||||
`platform-admins` gate — worth knowing before anyone treats that cooldown as a control.
|
||||
|
||||
### Keycloak client
|
||||
|
||||
|
||||
@@ -150,7 +150,27 @@ spec:
|
||||
# Single provider => 302 straight to Keycloak, no oauth2-proxy interstitial button.
|
||||
- --skip-provider-button=true
|
||||
- --redirect-url=https://crafty.${BASE_DOMAIN}/oauth2/callback
|
||||
- --cookie-domain=.${BASE_DOMAIN}
|
||||
# ── COOKIE ISOLATION (bug fix 2026-09-29) ────────────────────────────────────────────
|
||||
# This sidecar and the SHARED forward-auth oauth2-proxy (platform/oauth2-proxy) are
|
||||
# INDEPENDENT instances holding DIFFERENT cookie-secrets (verified in-cluster: the two
|
||||
# `cookie-secret` values differ, so neither can decrypt the other's cookie). Both used
|
||||
# to emit oauth2-proxy's DEFAULT cookie names (`_oauth2_proxy` and `_oauth2_proxy_csrf`)
|
||||
# scoped to `.${BASE_DOMAIN}`, so the browser kept ONE pair of cookies for BOTH proxies:
|
||||
# whichever authenticated last overwrote the other's session, and the loser then logged
|
||||
# "No valid authentication in request" and bounced to Keycloak -- which, thanks to the
|
||||
# live Keycloak SSO session, silently redirected back and overwrote the cookie again.
|
||||
# That mutual eviction presents EXACTLY as the reported symptom: "I signed in to
|
||||
# Keycloak but I am still not signed in to the app."
|
||||
# Two independent fixes, both applied here (only this side changes -- renaming the
|
||||
# SHARED proxy's cookie would sign every other gated app out):
|
||||
# 1. --cookie-name: a unique name, so the two sessions cannot alias at all.
|
||||
# 2. --cookie-domain: HOST-scoped. A per-app reverse proxy has no reason to broadcast
|
||||
# its session to every other host in the zone; the shared proxy keeps the wide
|
||||
# scope because cross-host forward-auth is precisely its job.
|
||||
# --whitelist-domain deliberately stays zone-wide: it governs permitted post-login
|
||||
# REDIRECT targets, not cookie scope.
|
||||
- --cookie-name=_oauth2_proxy_crafty
|
||||
- --cookie-domain=crafty.${BASE_DOMAIN}
|
||||
- --whitelist-domain=.${BASE_DOMAIN}
|
||||
- --cookie-secure=true
|
||||
- --pass-access-token=true
|
||||
|
||||
@@ -115,6 +115,12 @@ 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`:
|
||||
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.
|
||||
- **`crafty-admin-credential`** — you signed into Keycloak and Crafty *still* asks for a password.
|
||||
Expected: Crafty 4 has no OIDC, so its own local login sits behind the oauth2-proxy gate as a second
|
||||
factor. The page tells the two layers apart from the sidecar log, and explains that the bootstrap
|
||||
password exists **only** on the PVC (`default-creds.txt`, generated once on first boot, never in git).
|
||||
Includes the argon2 reset for a lost password, why the 3-strikes lockout cannot fire behind the
|
||||
sidecar, and why deleting `crafty.sqlite` to force a new password destroys every server definition.
|
||||
|
||||
### Virtualisation
|
||||
- **`kubevirt-control-plane`** — VM lifecycle operations will fail; running VMs keep running.
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
# crafty-admin-credential — Crafty's own login rejects you after Keycloak sign-in
|
||||
|
||||
**Not alert-driven.** This is an operator procedure, linked from
|
||||
[`platform/crafty/README.md`](../platform/crafty/README.md). It covers getting into the Crafty panel the
|
||||
first time, rotating that credential, and recovering when it is lost.
|
||||
|
||||
> **Everything below prints or sets a LIVE credential.** Do not paste the output into a PR, an issue, a
|
||||
> chat, or an AI agent transcript. Read it, use it, rotate it. (Repo rule §6: secrets never enter a
|
||||
> transcript.)
|
||||
|
||||
## What this means here
|
||||
|
||||
`crafty.home.keithjackson.dev` sits behind **two** independent authentications, and only the first is
|
||||
Keycloak's:
|
||||
|
||||
1. the **oauth2-proxy sidecar** in the Crafty pod — Keycloak `homelab` realm, group `platform-admins`;
|
||||
2. **Crafty's own local username/password login** — Crafty 4 has no OIDC and cannot consume the
|
||||
sidecar's identity headers, so it shows its own login page to an already-authenticated user.
|
||||
|
||||
So "I signed into Keycloak and I'm still not signed in" is the **expected** shape of this service, and
|
||||
the real question is only ever *which* of the two rejected you. They fail differently:
|
||||
|
||||
| Where you are | What you see | Which layer |
|
||||
|---|---|---|
|
||||
| Bounced to Keycloak repeatedly, never reach Crafty | sidecar log: `No valid authentication in request` | oauth2-proxy |
|
||||
| Authenticated but `403` | not in `platform-admins` | oauth2-proxy |
|
||||
| **Crafty's own login page, credentials refused** | `POST /api/v2/auth/login/` → **401** | **Crafty local** |
|
||||
|
||||
The third row is the one that blocks setup, because **nothing in the UI tells you where that password
|
||||
comes from**. Crafty generates it **once, on first boot only** — when the users table is empty
|
||||
(`main.py` `FRESH_INSTALL` → `DatabaseBuilder.default_settings()`) — and writes it to
|
||||
`/crafty/app/config/default-creds.txt` (mode `0600`) on the `config` subPath of the `crafty-data` PVC.
|
||||
It is **never in git** and **cannot be recovered from git**: a rebuild onto a fresh PVC generates a new
|
||||
random password, and the old one is gone with the old volume.
|
||||
|
||||
Two things that are *not* the problem, both verified live:
|
||||
|
||||
- **Repeated wrong guesses cannot lock you out.** Crafty *records* failed attempts under
|
||||
`get_remote_ip()` (honours `X-Forwarded-For`) but *checks* the 3-strikes cooldown under
|
||||
`self.request.remote_ip`, which behind the sidecar is always `127.0.0.1`. The halves key
|
||||
differently, so `max_login_attempts: 3` never fires. (It also means that throttle is not a control
|
||||
you have — the `platform-admins` gate is what limits who reaches the form at all.)
|
||||
- **Crafty's `set_passwd` console command is unreachable here.** It calls `getpass.getpass()` and needs
|
||||
a TTY; the container runs `main.py -d -i` as a daemon. Recovery goes through the DB instead.
|
||||
|
||||
## First commands
|
||||
|
||||
`kubectl` does not exist on the workstation — the toolchain lives only in the pinned dev container, and
|
||||
`dev.sh` picks a current-context **per invocation**, so always name the context.
|
||||
|
||||
**1. Is the pod healthy, and is the password still the generated one?**
|
||||
|
||||
```bash
|
||||
./infra/devcontainer/dev.sh run bash -c '
|
||||
kubectl --context k3s-prod -n crafty get pods -l app.kubernetes.io/name=crafty
|
||||
kubectl --context k3s-prod -n crafty exec deploy/crafty -c crafty -- python3 -c "
|
||||
import sqlite3
|
||||
c=sqlite3.connect(\"file:/crafty/app/config/db/crafty.sqlite?mode=ro\",uri=True)
|
||||
for r in c.execute(\"SELECT username,created,last_update,last_login FROM users WHERE user_id=1\"):
|
||||
print(\"created :\",r[1]); print(\"last_update:\",r[2]); print(\"last_login :\",r[3])
|
||||
"'
|
||||
```
|
||||
|
||||
Read `last_update` against `created`: **equal ⇒ the password has never been changed**, so
|
||||
`default-creds.txt` is still live. Different ⇒ someone rotated it and that file is stale — skip to
|
||||
*Reset*.
|
||||
|
||||
**2. Read the bootstrap credential** (prints a live secret — see the warning at the top):
|
||||
|
||||
```bash
|
||||
./infra/devcontainer/dev.sh run bash -c \
|
||||
'kubectl --context k3s-prod -n crafty exec deploy/crafty -c crafty -- cat /crafty/app/config/default-creds.txt'
|
||||
```
|
||||
|
||||
The username is `admin`. Sign in at `https://crafty.home.keithjackson.dev` (Keycloak first, then this).
|
||||
|
||||
**3. Rotate it immediately**, in the UI: user menu → your profile → change password. That is the only
|
||||
supported path that keeps Crafty's own bookkeeping consistent. Then confirm it took — re-run command 1
|
||||
and check `last_update` has moved away from `created`.
|
||||
|
||||
## Likely causes, most likely first
|
||||
|
||||
1. **You never had the password.** By far the most common, and the reason this runbook exists. The
|
||||
credential was only ever on the PVC. → commands 1–3 above.
|
||||
2. **The password was rotated and not recorded.** `last_update` ≠ `created` while nobody knows the
|
||||
value. `default-creds.txt` is now misleading, not authoritative. → *Reset* below.
|
||||
3. **The PVC was rebuilt.** A fresh volume means a fresh random password *and* an empty panel — no
|
||||
servers, no worlds (`platform/crafty/README.md`, "the servers themselves are NOT in git"). Read
|
||||
`default-creds.txt` from the new pod; do not expect the old credential to work.
|
||||
4. **You are actually stuck at the oauth2-proxy layer, not Crafty's.** Check which layer is talking
|
||||
before touching any password:
|
||||
```bash
|
||||
./infra/devcontainer/dev.sh run bash -c \
|
||||
'kubectl --context k3s-prod -n crafty logs deploy/crafty -c oauth2-proxy --tail=200 | grep -v kube-probe'
|
||||
```
|
||||
`[AuthSuccess] ... groups:[platform-admins]` followed by a `200` on `/login` means oauth2-proxy did
|
||||
its job and the 401 is Crafty's. Repeated `No valid authentication in request` with no `AuthSuccess`
|
||||
means the session cookie is not surviving — see *cookie isolation* in
|
||||
`platform/crafty/README.md`; this sidecar must keep its own `--cookie-name` distinct from the shared
|
||||
proxy's, or the two evict each other.
|
||||
5. **`platform-admins` membership lapsed.** A `403` rather than a login page. Fix in Keycloak, not here.
|
||||
|
||||
## Reset when the password is unknown
|
||||
|
||||
Crafty stores an **argon2** hash in `users.password`. This writes a fresh random password, invalidates
|
||||
existing sessions, and drops the new value into a `0600` file **inside the pod** — deliberately
|
||||
mirroring Crafty's own `default-creds.txt` idiom so the secret never crosses argv, stdin or shell
|
||||
history. (`dev.sh` does **not** forward stdin — a `kubectl exec -i` here hangs forever, which is why the
|
||||
password is generated in-pod rather than passed in.)
|
||||
|
||||
Two details that will otherwise cost you the afternoon: argon2 is installed **only** in Crafty's venv,
|
||||
so it must be `/crafty/.venv/bin/python`, not the system `python3`; and this is a **mutating operator
|
||||
action**, not an agent action.
|
||||
|
||||
```bash
|
||||
./infra/devcontainer/dev.sh run bash -c \
|
||||
'kubectl --context k3s-prod -n crafty exec deploy/crafty -c crafty -- /crafty/.venv/bin/python -c "
|
||||
import os, secrets, sqlite3, string, datetime
|
||||
from argon2 import PasswordHasher
|
||||
pw = \"\".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(24))
|
||||
now = datetime.datetime.now()
|
||||
c = sqlite3.connect(\"/crafty/app/config/db/crafty.sqlite\", timeout=30)
|
||||
cur = c.execute(
|
||||
\"UPDATE users SET password=?, last_update=?, valid_tokens_from=? WHERE username=?\",
|
||||
(PasswordHasher().hash(pw), str(now),
|
||||
str(datetime.datetime.now(datetime.timezone.utc)), \"admin\"),
|
||||
)
|
||||
c.commit()
|
||||
print(\"rows updated:\", cur.rowcount)
|
||||
out = \"/crafty/app/config/reset-creds.txt\"
|
||||
open(out, \"w\").write(pw + chr(10))
|
||||
os.chmod(out, 0o600)
|
||||
print(\"new password written to\", out)
|
||||
"'
|
||||
```
|
||||
|
||||
Expect `rows updated: 1`. Then read it, sign in, rotate in the UI, and delete the file:
|
||||
|
||||
```bash
|
||||
./infra/devcontainer/dev.sh run bash -c \
|
||||
'kubectl --context k3s-prod -n crafty exec deploy/crafty -c crafty -- cat /crafty/app/config/reset-creds.txt'
|
||||
# after signing in and rotating:
|
||||
./infra/devcontainer/dev.sh run bash -c \
|
||||
'kubectl --context k3s-prod -n crafty exec deploy/crafty -c crafty -- rm -f /crafty/app/config/reset-creds.txt'
|
||||
```
|
||||
|
||||
`rows updated: 0` means the username is not `admin` — list the accounts (names only, never the
|
||||
`password` column) and retry against the right one:
|
||||
|
||||
```bash
|
||||
./infra/devcontainer/dev.sh run bash -c \
|
||||
'kubectl --context k3s-prod -n crafty exec deploy/crafty -c crafty -- python3 -c "
|
||||
import sqlite3
|
||||
c=sqlite3.connect(\"file:/crafty/app/config/db/crafty.sqlite?mode=ro\",uri=True)
|
||||
print([r[0] for r in c.execute(\"SELECT username FROM users\")])
|
||||
"'
|
||||
```
|
||||
|
||||
`database is locked` means Crafty held the write lock; the `timeout=30` usually rides it out, otherwise
|
||||
retry. The panel picks the new hash up on the next login attempt — no restart needed, and a restart
|
||||
costs a Minecraft-server stop (`strategy: Recreate` on an RWO volume), so do not restart reflexively.
|
||||
|
||||
## When to escalate or stop
|
||||
|
||||
- **Stop** as soon as you can sign in, and **rotate immediately** — do not leave the generated
|
||||
bootstrap password in place as the standing admin credential, and do not leave `reset-creds.txt` on
|
||||
the PVC.
|
||||
- **Do not `kubectl edit`/`patch`/`apply`** the Deployment to work around a login problem. Flux
|
||||
reconciles `main`; hand-applied changes are drift. Manifest changes go through a branch and a PR.
|
||||
- **Do not delete `crafty.sqlite`** to force a "fresh install" and a new generated password. It holds
|
||||
every server definition — jar, ports, heap, `auto_start` — and losing it means recreating each server
|
||||
by hand, re-accepting the Mojang EULA, and re-`PATCH`ing `auto_start`. The worlds under
|
||||
`/crafty/servers/<uuid>/` are not in git and not recreatable. The reset above is always cheaper.
|
||||
- **Escalate to the Keycloak side** (`runbooks/keycloak-down.md`,
|
||||
`runbooks/keycloak-client-secrets.md`) if there is no `AuthSuccess` in the sidecar log at all — that
|
||||
is an IdP or client-secret problem and no Crafty password will help.
|
||||
- **If `default-creds.txt` is absent on a pod whose DB says the password was never changed**, stop and
|
||||
hand off: that combination should not occur, and it suggests the `config` subPath is not the volume
|
||||
you think it is. Record what you measured rather than guessing.
|
||||
@@ -60,6 +60,9 @@ configMapGenerator:
|
||||
- cnpg-exporter=cnpg-exporter.md
|
||||
- cnpg-primary-spread=cnpg-primary-spread.md
|
||||
- cnpg-wal-growth=cnpg-wal-growth.md
|
||||
# Operator procedure, not alert-linked (Crafty's local login is a second factor behind
|
||||
# the Keycloak gate and its bootstrap password lives only on the PVC).
|
||||
- crafty-admin-credential=crafty-admin-credential.md
|
||||
- denied-flows=denied-flows.md
|
||||
- flux-reconcile=flux-reconcile.md
|
||||
- flux-suspended=flux-suspended.md
|
||||
|
||||
Reference in new issue
Block a user