The audit found the new machinery could do more harm than the bugs it prevents. - apply.sh reused the 'nothing left to do' exit -- which touches the PERMANENT kill switch, cleared only by install.sh, never by update.sh -- for the incompatible case. On TrueNAS 26 (providers ok, nested opt-out) both modules go quiet, so the switch would fire and the release that fixed 26 could never re-enable itself. Retirement and incompatibility now take different exits. - A network blip, a re-export, or a conditional def all read as BROKEN. Each is now 'unknown', which changes nothing, rather than evidence strong enough to disable a module. - 'native' outranked BROKEN everywhere but apply.sh, so a TrueNAS that reworded the guard AND reshaped the functions rendered as good news. - compat.py --tree read B2_BLOCK's own 'restic = True' as native support, so the documented way to check a live box lied on every patched machine. - The signature check was a name-subset test. It passed reorders, kw-only conversions, and added required params -- and it had already passed a real bug: restic_backup takes 4 args on 24.10/25.04, and the wrapper forwarded 5. Nested backups have been raising TypeError on those releases the whole time. The wrapper now forwards *args/**kwargs. - release.sh --promote was unreachable: it died if the tag existed, the gate died if it did not. The tests hid it by always tagging first.
217 lines
9.1 KiB
YAML
217 lines
9.1 KiB
YAML
name: TrueNAS compatibility
|
|
|
|
# Find out that iX broke us BEFORE their release ships, not after a user's backup
|
|
# fails.
|
|
#
|
|
# This patch appends code to middlewared's internal modules. There is no stability
|
|
# contract: TrueNAS 26 rewrote the whole cloud_backup path from async to sync, and
|
|
# every block the nested module injects is an `async def` wrapping an `await`ed
|
|
# original. Nobody would have found out until a restore did not work.
|
|
#
|
|
# tools/compat.py records what the patch assumes and checks it against iX's actual
|
|
# source at every release line -- including master and the current BETA/RC, which is
|
|
# where a break shows up first. When an UNRELEASED line breaks, this opens a bug
|
|
# report so there is time to fix it before that version reaches anyone.
|
|
#
|
|
# Runs on both forges: Gitea (canonical) and GitHub (mirror). Only the "file an
|
|
# issue" call differs.
|
|
|
|
on:
|
|
schedule:
|
|
- cron: "17 6 * * *" # daily, off the hour: everyone crons on the hour
|
|
workflow_dispatch:
|
|
push:
|
|
paths:
|
|
# The manifest itself changed -- re-check immediately rather than waiting a day.
|
|
- "tools/compat.py"
|
|
- ".github/workflows/compat.yml"
|
|
|
|
permissions:
|
|
contents: read
|
|
issues: write
|
|
|
|
jobs:
|
|
compat:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- uses: actions/setup-python@v5
|
|
with:
|
|
python-version: "3.13"
|
|
|
|
# ONE pass over the network. Every other step renders from this JSON -- calling
|
|
# compat.py three times would re-fetch every file from every release line three
|
|
# times, and could even disagree with itself if iX pushed mid-run.
|
|
#
|
|
# The exit code is CAPTURED, not allowed to abort the step: a nonzero exit means
|
|
# "a shipped release is broken", which is a result to report, not a reason to
|
|
# die before reporting it. (Actions runs `bash -e`, so `cmd > out` followed by
|
|
# `echo $?` never reaches the echo.) It becomes a job failure at the end, after
|
|
# the bug report has been filed.
|
|
- name: check every TrueNAS release line
|
|
id: check
|
|
run: |
|
|
rc=0
|
|
python3 tools/compat.py --matrix --json > /tmp/matrix.json || rc=$?
|
|
echo "shipped_broken=$rc" >> "$GITHUB_OUTPUT"
|
|
|
|
# The report body is written to a FILE, and never becomes a step output.
|
|
#
|
|
# An earlier version did `echo "${{ steps.report.outputs.body }}"`, which
|
|
# splices the text into the shell script itself -- and the report is full of
|
|
# backticks, so bash ran `create-snapshot`, `def` and `async` as commands. It
|
|
# is also an injection vector: the report is built from iX's source, so
|
|
# anything that lands in middleware would execute on the runner.
|
|
#
|
|
# The rule that avoids the whole class: never interpolate ${{ }} into a `run:`
|
|
# body. Files for data, `env:` for scalars (the runner sets those, rather than
|
|
# pasting them into the script).
|
|
- name: build the report
|
|
id: report
|
|
run: |
|
|
python3 - <<'PY' >> "$GITHUB_OUTPUT"
|
|
import json, sys
|
|
|
|
sys.path.insert(0, "tools")
|
|
import compat
|
|
|
|
with open("/tmp/matrix.json") as fh:
|
|
rows = json.load(fh)
|
|
|
|
with open("/tmp/matrix.md", "w") as fh:
|
|
fh.write(compat.render_markdown(rows))
|
|
|
|
broken = [
|
|
r for r in rows
|
|
if any(compat.is_broken(m) for m in r["modules"].values())
|
|
]
|
|
native = [
|
|
(r["ref"], mod)
|
|
for r in rows
|
|
for mod, m in sorted(r["modules"].items())
|
|
if m["native"] and not compat.is_broken(m)
|
|
]
|
|
|
|
lines = [
|
|
"`tools/compat.py` found that the patch's assumptions about "
|
|
"middlewared no longer hold.",
|
|
"",
|
|
compat.render_markdown(rows),
|
|
"",
|
|
]
|
|
for r in broken:
|
|
lines.append(f"### {r['ref']}")
|
|
lines.append("")
|
|
for mod, m in sorted(r["modules"].items()):
|
|
if not compat.is_broken(m):
|
|
continue
|
|
lines.append(f"**{mod}** — the patch will not apply:")
|
|
lines.append("")
|
|
for p in m["problems"]:
|
|
lines.append(f"- `{p['id']}`: {p['detail']}")
|
|
lines.append(f" - why it matters: {p['why']}")
|
|
lines.append("")
|
|
for ref, mod in native:
|
|
lines.append(
|
|
f"- `{ref}`: **{mod}** appears to be NATIVE now — retire the "
|
|
f"module rather than fixing it."
|
|
)
|
|
lines += ["", "_Filed automatically by `.github/workflows/compat.yml`._"]
|
|
|
|
with open("/tmp/issue.md", "w") as fh:
|
|
fh.write("\n".join(lines))
|
|
|
|
# Scalars only. The body stays in the file.
|
|
print(f"broken={'1' if broken else '0'}")
|
|
print(f"refs={','.join(r['ref'] for r in broken)}")
|
|
PY
|
|
|
|
- name: matrix
|
|
run: cat /tmp/matrix.md
|
|
|
|
# Keep the README's table true. A support matrix that quietly goes stale is not
|
|
# a stale doc -- it is a false promise to somebody deciding whether to trust
|
|
# this with their backups.
|
|
#
|
|
# Only ever touches the block between the COMPAT MATRIX markers, and only on
|
|
# the canonical host (Gitea) so the two forges cannot race each other. The
|
|
# `paths:` trigger above does not include README.md, so this cannot re-trigger
|
|
# itself; and a README change is documentation-only, which by design raises no
|
|
# update alert on anyone's box.
|
|
- name: refresh the README matrix
|
|
if: ${{ github.event_name == 'schedule' && !contains(github.server_url, 'github.com') }}
|
|
run: |
|
|
python3 - <<'PY'
|
|
import json, sys
|
|
sys.path.insert(0, "tools")
|
|
import compat
|
|
with open("/tmp/matrix.json") as fh:
|
|
rows = json.load(fh)
|
|
print("changed" if compat.update_readme(rows) else "unchanged")
|
|
PY
|
|
|
|
if ! git diff --quiet -- README.md; then
|
|
git config user.name "truecloud-patch bot"
|
|
git config user.email "bot@onetick.ninja"
|
|
git add README.md
|
|
git commit -m "docs: refresh the TrueNAS compatibility matrix"
|
|
git push origin HEAD:main
|
|
fi
|
|
|
|
# A broken SHIPPED release is an outage: users are on it right now.
|
|
- name: fail if a shipped release is broken
|
|
if: ${{ steps.check.outputs.shipped_broken != '0' }}
|
|
run: |
|
|
echo "::error::The patch is broken on a SHIPPED TrueNAS release."
|
|
exit 1
|
|
|
|
- name: file a bug report (GitHub)
|
|
if: ${{ steps.report.outputs.broken == '1' && contains(github.server_url, 'github.com') }}
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
TITLE: "Incompatible with upcoming TrueNAS: ${{ steps.report.outputs.refs }}"
|
|
run: |
|
|
# One issue per set of broken refs, reopened/updated rather than duplicated
|
|
# daily -- a bot that files the same issue every morning gets muted, and
|
|
# then it is not a warning system any more.
|
|
existing="$(gh issue list --state all --search "$TITLE" \
|
|
--json number,title \
|
|
--jq '.[] | select(.title == env.TITLE) | .number' | head -1)"
|
|
|
|
if [ -n "$existing" ]; then
|
|
gh issue comment "$existing" --body-file /tmp/issue.md
|
|
gh issue reopen "$existing" 2>/dev/null || true
|
|
else
|
|
gh issue create --title "$TITLE" --body-file /tmp/issue.md
|
|
fi
|
|
|
|
- name: file a bug report (Gitea)
|
|
if: ${{ steps.report.outputs.broken == '1' && !contains(github.server_url, 'github.com') }}
|
|
env:
|
|
TOKEN: ${{ secrets.GITEA_TOKEN || github.token }}
|
|
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
|
TITLE: "Incompatible with upcoming TrueNAS: ${{ steps.report.outputs.refs }}"
|
|
run: |
|
|
body="$(jq -Rs . < /tmp/issue.md)"
|
|
title="$(printf '%s' "$TITLE" | jq -Rs .)"
|
|
|
|
# Same title => same issue. Comment on it instead of filing a new one.
|
|
number="$(curl -sf -H "Authorization: token $TOKEN" \
|
|
"$API/issues?state=all&type=issues" \
|
|
| jq -r --arg t "$TITLE" '.[] | select(.title == $t) | .number' | head -1)"
|
|
|
|
if [ -n "$number" ]; then
|
|
curl -sS -X POST "$API/issues/$number/comments" \
|
|
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
|
-d "$(printf '{"body":%s}' "$body")" -o /dev/null -w 'comment -> %{http_code}\n'
|
|
curl -sS -X PATCH "$API/issues/$number" \
|
|
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
|
-d '{"state":"open"}' -o /dev/null -w 'reopen -> %{http_code}\n'
|
|
else
|
|
curl -sS -X POST "$API/issues" \
|
|
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
|
-d "$(printf '{"title":%s,"body":%s}' "$title" "$body")" \
|
|
-o /dev/null -w 'create -> %{http_code}\n'
|
|
fi
|