fix(bitcoin): repair the startup script I broke in 1.7.124, and gate against it
The bitcoin app vanished from updated nodes: the container exited instantly with 'sh: Syntax error: "fi" unexpected'. My 1.7.124 change added an explanatory comment INSIDE the manifest's folded YAML scalar (>-), where '#' is not a comment — it is literal text that reaches the shell. Folding joins lines with spaces, so the comment swallowed the 'if ... then' while the more-indented echo survived as its own line, leaving an orphan 'fi'. bitcoind never ran, the container exited, and the app disappeared from the UI because detection is container-based. Explanations now live above the '- >-' line where YAML really treats them as comments. The loopback-conf tolerance (-allowignoredconf=1) is unchanged and still needed. Adds scripts/check-manifest-shell.py to the release gate: it runs 'sh -n' over every embedded manifest script and rejects '#' inside these scalars. Nothing validated this shell before — no YAML parse or Rust test could have caught it, and it only failed on the node, after signing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
c9f1d87dd6
commit
e88c51d80b
@@ -38,15 +38,8 @@ app:
|
||||
RPC_CONF="/tmp/rpc.conf";
|
||||
umask 077;
|
||||
{ echo "rpcuser=$RPC_USER"; echo "rpcpassword=$RPC_PASS"; } > "$RPC_CONF";
|
||||
# A stray bitcoin.conf in the datadir is FATAL when -conf points
|
||||
# elsewhere: bitcoind refuses to start with "contains a bitcoin.conf
|
||||
# file which is ignored", and the app crash-loops (100.82.34.38,
|
||||
# 2026-08-05 — Exited(1) every few seconds). Our -conf carries the
|
||||
# RPC credentials and the flags below are the authoritative config,
|
||||
# so the datadir file is legacy debris; say so out loud rather than
|
||||
# failing, and let bitcoind start.
|
||||
if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then
|
||||
echo "archipelago: ignoring legacy /home/bitcoin/.bitcoin/bitcoin.conf; RPC config comes from $RPC_CONF and the flags below" >&2;
|
||||
echo "archipelago: ignoring legacy datadir bitcoin.conf; RPC config comes from $RPC_CONF" >&2;
|
||||
fi;
|
||||
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
|
||||
DISK_GB_VALUE="$(printenv DISK_GB || true)";
|
||||
|
||||
@@ -38,15 +38,8 @@ app:
|
||||
RPC_CONF="/tmp/rpc.conf";
|
||||
umask 077;
|
||||
{ echo "rpcuser=$RPC_USER"; echo "rpcpassword=$RPC_PASS"; } > "$RPC_CONF";
|
||||
# A stray bitcoin.conf in the datadir is FATAL when -conf points
|
||||
# elsewhere: bitcoind refuses to start with "contains a bitcoin.conf
|
||||
# file which is ignored", and the app crash-loops (100.82.34.38,
|
||||
# 2026-08-05 — Exited(1) every few seconds). Our -conf carries the
|
||||
# RPC credentials and the flags below are the authoritative config,
|
||||
# so the datadir file is legacy debris; say so out loud rather than
|
||||
# failing, and let bitcoind start.
|
||||
if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then
|
||||
echo "archipelago: ignoring legacy /home/bitcoin/.bitcoin/bitcoin.conf; RPC config comes from $RPC_CONF and the flags below" >&2;
|
||||
echo "archipelago: ignoring legacy datadir bitcoin.conf; RPC config comes from $RPC_CONF" >&2;
|
||||
fi;
|
||||
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
|
||||
DISK_GB_VALUE="$(printenv DISK_GB || true)";
|
||||
|
||||
Executable
+86
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Syntax-check the shell embedded in apps/*/manifest.yml.
|
||||
|
||||
A manifest can carry a whole startup script in `container.custom_args` /
|
||||
`entrypoint`. Nothing validated it, so a broken one shipped through the
|
||||
signed catalog and only failed on the node — as a container that exits
|
||||
instantly and an app that vanishes from the UI.
|
||||
|
||||
Two checks, both learned from v1.7.124 (bitcoin-knots / bitcoin-core):
|
||||
|
||||
1. `sh -n` the snippet. The break was `sh: Syntax error: "fi" unexpected`,
|
||||
which no YAML parse and no Rust test could have caught.
|
||||
|
||||
2. Reject `#` inside the snippet. These are YAML **folded** scalars (`>-`),
|
||||
where `#` is NOT a comment — it is literal text that reaches the shell,
|
||||
and because folding joins lines with spaces it comments out the rest of
|
||||
the folded line. That is exactly how an `if ... then` was swallowed while
|
||||
its more-indented body survived, leaving an orphan `fi`. Put explanations
|
||||
above the `- >-` line, where YAML really does treat them as comments.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import yaml
|
||||
|
||||
# Long enough to be a script rather than a flag.
|
||||
MIN_SCRIPT_LEN = 60
|
||||
|
||||
|
||||
def snippets(path: str):
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
container = ((data or {}).get("app") or {}).get("container") or {}
|
||||
for key in ("custom_args", "entrypoint"):
|
||||
value = container.get(key)
|
||||
if not isinstance(value, list):
|
||||
continue
|
||||
for i, part in enumerate(value):
|
||||
if isinstance(part, str) and len(part) >= MIN_SCRIPT_LEN:
|
||||
yield f"{key}[{i}]", part
|
||||
|
||||
|
||||
def main() -> int:
|
||||
failures = []
|
||||
checked = 0
|
||||
for path in sorted(glob.glob("apps/*/manifest.yml")):
|
||||
app = os.path.basename(os.path.dirname(path))
|
||||
try:
|
||||
found = list(snippets(path))
|
||||
except Exception as exc: # noqa: BLE001 — report, don't crash the gate
|
||||
failures.append(f"{app}: manifest does not parse: {exc}")
|
||||
continue
|
||||
for where, script in found:
|
||||
checked += 1
|
||||
if "#" in script:
|
||||
failures.append(
|
||||
f"{app} {where}: contains '#'. In a folded YAML scalar that is not a "
|
||||
f"comment — it reaches the shell and comments out the rest of the "
|
||||
f"folded line. Move the explanation above the '- >-' line."
|
||||
)
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".sh", delete=False) as tmp:
|
||||
tmp.write(script)
|
||||
tmp_path = tmp.name
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["sh", "-n", tmp_path], capture_output=True, text=True, check=False
|
||||
)
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
if proc.returncode != 0:
|
||||
failures.append(f"{app} {where}: {proc.stderr.strip()}")
|
||||
|
||||
for f in failures:
|
||||
print(f"MANIFEST-SHELL {f}", file=sys.stderr)
|
||||
print(f'{{"snippets_checked": {checked}, "failures": {len(failures)}}}')
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -62,6 +62,7 @@ summary() {
|
||||
# ── Stage 1: static ──────────────────────────────────────────────────
|
||||
stage "git-diff-check" git diff --check
|
||||
stage "cargo-fmt" timeout 240 cargo fmt --manifest-path core/Cargo.toml --all --check
|
||||
stage "manifest-shell" python3 scripts/check-manifest-shell.py
|
||||
stage "catalog-drift" python3 scripts/check-app-catalog-drift.py --release --strict
|
||||
# Every release must surface its CHANGELOG entry in the Settings "What's New"
|
||||
# modal. The modal hardcodes a block per version and has drifted behind before
|
||||
|
||||
Reference in New Issue
Block a user