87 lines
3.1 KiB
Python
87 lines
3.1 KiB
Python
#!/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())
|