diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 3a631b2..0ed60bf 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -1572,3 +1572,46 @@ If an MQTT broker is available the add-on publishes setpoint, grid power, battery power, circulating power, state of charge, maintenance phase and controller status by MQTT discovery. This is observability only — the controller works fine without a broker, and MQTT problems can never affect control. + +## Before you deploy: `tools/predeploy_check.py` (PKG-09) + +Two ways a deploy has gone wrong on this system, both of which the Supervisor +reports as success: + +**The silent no-op.** The Supervisor keys the built image by `version:` in +`config.yaml`. `ha apps update` against an unchanged version installs nothing +and says it worked. This happened twice: PKG-06 (six merges behind an unbumped +version) and PKG-07 (six again). Both times a human noticed. Nothing else would have. + +**The refused config.** A config the shipped validator rejects is +`SystemExit(78)` — the add-on does not start. ⚠️ **And the ingress panel goes +with it**, because the add-on serving it is not running, so recovery means the +Supervisor UI. SAFETY-39's rule 10 made this reachable from a config that +started fine on the previous version: 0.7.0's own default `slew_w: 1000` +exceeds the 555.6 W ceiling on a 5000 W rail. + +Run it inside the container, so the validator half actually runs: + + docker run --rm -v :/w:ro goodwe-addon-checks:1 \ + python3 /w/goodwe_controller/tools/predeploy_check.py \ + --deployed-version 0.7.0 --options /w/live-options.json + +Export `live-options.json` from the Supervisor first — the options blob is not +reachable through the Home Assistant REST API, and no entity carries it. + +**Exit codes.** `0` cleared · `1` REFUSED, do not deploy · `2` NOT CLEARED, +meaning a check did not run. ⚠️ **2 is not a pass.** On a bare host without the +add-on's dependencies the validator half skips and returns 2 rather than +pretending; `test_version_gate.py` pins that a skipped check can never exit 0, +because the first draft of that test reported four silent skips as passes. + +⚠️ **Why this is not a CI check.** The obvious gate — "a pull request touching +runtime code must bump the version" — is the wrong shape here. This project +deliberately batches many merges into one deploy, so a per-PR bump would put +every branch in conflict over one line and still not prevent the failure, which +happens at deploy and not at merge. The check belongs where the mistake is made. + +⚠️ This file's own change does **not** need a version bump, and the gate agrees: +`tools/` and `test_*.py` are not in its runtime set, because demanding a bump +for a typo fix trains people to bump without reading — the habit the check +exists to prevent. diff --git a/goodwe_controller/test_version_gate.py b/goodwe_controller/test_version_gate.py new file mode 100644 index 0000000..e579336 --- /dev/null +++ b/goodwe_controller/test_version_gate.py @@ -0,0 +1,244 @@ +"""PKG-09 - grade the check that refuses a no-op deploy. `python3 test_version_gate.py` + +⚠️ THE POINT OF THIS FILE IS THE REFUSALS, NOT THE CLEARANCES. A pre-deploy +check that only ever says "CLEARED" is the defect `MUTATION.md` habit 4 names - +"a check whose DATA cannot reach the failure it names" - which this repository +has recorded twelve times. So every refusal below is DRIVEN: a repository is +built in which the failure is really present, and the check is required to say +so. A clearance is only meaningful next to a refusal from the same harness. + +The third property is the one that is easy to lose: a SKIPPED check must not +look like a PASSED one. Omitting `--options` cannot exit 0. +""" +import json +import os +import shutil +import subprocess +import sys +import tempfile + +HERE = os.path.dirname(os.path.abspath(__file__)) +GATE = os.path.join(HERE, "tools", "predeploy_check.py") +sys.path.insert(0, os.path.join(HERE, "tools")) + +fails = [] +tmpdirs = [] + + +def check(name, ok): + print(f" {'ok ' if ok else 'FAIL'} {name}") + if not ok: + fails.append(name) + + +def git(repo, *args): + return subprocess.run( + ["git", "-C", repo, "-c", "user.email=t@t", "-c", "user.name=t", *args], + capture_output=True, text=True) + + +def make_repo(version="0.7.0"): + """A repo shaped like this one: /goodwe_controller/{config.yaml,app,tools}.""" + root = tempfile.mkdtemp(prefix="test_version_gate.") + tmpdirs.append(root) + addon = os.path.join(root, "goodwe_controller") + os.makedirs(os.path.join(addon, "app")) + write(addon, "config.yaml", f'name: t\nversion: "{version}"\n') + write(addon, "app/control.py", "X = 1\n") + write(addon, "CHANGELOG.md", "# Changelog\n") + git(root, "init", "-q", ".") + git(root, "add", "-A") + git(root, "commit", "-q", "-m", f"initial at {version}") + return root, addon + + +def write(addon, rel, text): + p = os.path.join(addon, rel) + with open(p, "w", encoding="utf-8") as fh: + fh.write(text) + + +def run_gate(addon, root, deployed, options=None): + """Run the REAL gate against a throwaway tree. + + ⚠️ The script is invoked where it SHIPS, not copied into the fixture. It + imports the validator from its own sibling `app/`, which is the behaviour + that matters: the gate must check a config against the validator it ships + beside, not against whatever happens to sit next to a copy. Copying it into + the fixture made every validator assertion silently SKIP - the first draft + of this file did exactly that and reported four green skips as passes. + """ + cmd = [sys.executable, GATE, + "--deployed-version", deployed, "--tree", addon, "--repo", root] + if options: + cmd += ["--options", options] + return subprocess.run(cmd, capture_output=True, text=True) + + +VALID = {"max_w": 2000, "slew_w": 0, "target_grid_w": -10, "gain": 0.6, + "inverters": [], "setpoint_entity": "number.goodwe_setpoint_w"} + + +def opts_file(d): + fd, p = tempfile.mkstemp(suffix=".json") + with os.fdopen(fd, "w") as fh: + json.dump(d, fh) + return p + + +print("classification - what forces a rebuild and what does not") +from predeploy_check import is_runtime, shipped_version # noqa: E402 + +for p in ("goodwe_controller/app/control.py", "goodwe_controller/config.yaml", + "goodwe_controller/translations/en.yaml", "goodwe_controller/run.sh"): + check(f"runtime: {p}", is_runtime(p)) +for p in ("goodwe_controller/CHANGELOG.md", "goodwe_controller/DOCS.md", + "goodwe_controller/test_control.py", "goodwe_controller/tools/x.py", + "goodwe_controller/DECISIONS/a.md", "README.md"): + check(f"NOT runtime: {p}", not is_runtime(p)) + +print("\nTHE REFUSAL - a runtime change behind an unchanged version") +root, addon = make_repo("0.7.0") +write(addon, "app/control.py", "X = 2\n") +git(root, "commit", "-qam", "SAFETY-99: change behaviour, bump nothing") +r = run_gate(addon, root, "0.7.0", opts_file(VALID)) +check("refuses (exit 1)", r.returncode == 1) +check("...and says the deploy would be a NO-OP", "NO-OP" in r.stdout) +check("...and names the offending commit", "SAFETY-99" in r.stdout) +check("...and does NOT say cleared", "CLEARED for deploy" not in r.stdout) + +print("\nPOSITIVE CONTROL - the same check must NOT fire on a docs-only change") +root2, addon2 = make_repo("0.7.0") +write(addon2, "CHANGELOG.md", "# Changelog\n\n## notes\n") +git(root2, "commit", "-qam", "PKG-99: documentation only") +r2 = run_gate(addon2, root2, "0.7.0", opts_file(VALID)) +check("does not refuse a docs-only tree", r2.returncode == 0) +check("...and says there is nothing to deploy", + "nothing to deploy" in r2.stdout) + +print("\nTHE CLEARANCE - a bumped version with a valid config") +root3, addon3 = make_repo("0.7.0") +write(addon3, "app/control.py", "X = 3\n") +write(addon3, "config.yaml", 'name: t\nversion: "0.8.0"\n') +git(root3, "commit", "-qam", "PKG-99: change and bump together") +r3 = run_gate(addon3, root3, "0.7.0", opts_file(VALID)) +check("clears (exit 0)", r3.returncode == 0) +check("...and says so", "CLEARED for deploy" in r3.stdout) + +print("\n⭐ A SKIPPED CHECK MUST NOT LOOK LIKE A PASSED ONE") +r4 = run_gate(addon3, root3, "0.7.0") # no --options +check("omitting --options cannot exit 0", r4.returncode != 0) +check("...says SKIPPED", "SKIPPED" in r4.stdout) +check("...says the config was NEVER CHECKED", "NEVER CHECKED" in r4.stdout) +check("...and does NOT claim clearance", "CLEARED for deploy" not in r4.stdout) + +print("\nTHE OTHER REFUSAL - a config this version's validator rejects") +bad = dict(VALID, slew_w=1000) # rule 10: > 2/18 x 2000 = 222 W +r5 = run_gate(addon3, root3, "0.7.0", opts_file(bad)) +check("refuses a config the validator rejects", r5.returncode == 1) +check("...and warns it is exit 78", "78" in r5.stdout) +check("...and warns the ingress panel goes too", "ingress panel" in r5.stdout) +check("...and prints the validator's own words", "slew_w is 1000 W" in r5.stdout) + +print() +print("⭐ HISTORY WITH MORE THAN ONE BUMP - THE FIXTURE THE FIRST DRAFT LACKED") +# ⚠️ EVERY FIXTURE ABOVE BUILDS A FRESH REPOSITORY WHOSE `version:` LINE IS +# INTRODUCED ONCE AND NEVER EDITED. That is precisely the shape in which the +# original `git log -S 'version: "'` looked correct - the pickaxe finds commits +# that change a string's COUNT, and introducing the line changes it from 0 to 1. +# A real repository edits the line IN PLACE, the count never moves again, and +# the lookup silently returns the commit that first added it. +# +# MEASURED on admin/goodwe-addon before the fix: `-S` named `0a1e61d` +# (2026-08-23, the original packaging commit) as "the last bump", so a correctly +# bumped tree was reported as 87 commits behind an unbumped version - with +# PKG-06's and PKG-07's own bump commits listed among the offenders. The tool +# cried wolf on every run, and none of the fixtures above could see it, because +# a test that builds its own one-bump history is a test asserting against a +# structure it built itself. MUTATION.md habit 4, in the file written to +# prevent a recurrence. +from predeploy_check import last_version_change # noqa: E402 + +root4 = tempfile.mkdtemp(prefix="test_version_gate.hist.") +tmpdirs.append(root4) +addon4 = os.path.join(root4, "goodwe_controller") +os.makedirs(os.path.join(addon4, "app")) +write(addon4, "config.yaml", 'name: t\nversion: "0.5.0"\n') +write(addon4, "app/control.py", "X = 1\n") +git(root4, "init", "-q", ".") +git(root4, "add", "-A") +git(root4, "commit", "-q", "-m", "initial at 0.5.0") +_first = git(root4, "rev-parse", "HEAD").stdout.strip() + +write(addon4, "app/control.py", "X = 2\n") +git(root4, "commit", "-qam", "runtime change between bumps") +write(addon4, "config.yaml", 'name: t\nversion: "0.6.0"\n') +git(root4, "commit", "-qam", "bump to 0.6.0") +write(addon4, "app/control.py", "X = 3\n") +git(root4, "commit", "-qam", "another runtime change") +write(addon4, "config.yaml", 'name: t\nversion: "0.7.0"\n') +git(root4, "commit", "-qam", "bump to 0.7.0") +_last_bump = git(root4, "rev-parse", "HEAD").stdout.strip() + +check("⭐ THE LAST BUMP IS FOUND, NOT THE FIRST. An in-place edit of the " + "version line does not change how many times the string occurs, so a " + "pickaxe search returns the commit that introduced it and every bump " + "after that is invisible", + last_version_change(root4) == _last_bump) +check("...and it is emphatically not the commit that introduced the line", + last_version_change(root4) != _first) + +r6 = run_gate(addon4, root4, "0.7.0", opts_file(VALID)) +check("a tree bumped with nothing after it clears, even with bumps in history", + r6.returncode == 0 and "CLEARED for deploy" in r6.stdout) + +write(addon4, "app/control.py", "X = 4\n") +git(root4, "commit", "-qam", "SAFETY-98: runtime change after the last bump") +r7 = run_gate(addon4, root4, "0.7.0", opts_file(VALID)) +check("and one runtime commit after that last bump is refused", r7.returncode == 1) +check("...naming only the commit since the LAST bump, not since the first", + "SAFETY-98" in r7.stdout and "runtime change between bumps" not in r7.stdout) + + +# ⚠️ AND THE FIXTURE ABOVE STILL COULD NOT TELL TWO IMPLEMENTATIONS APART. +# Review swapped `last_version_change` for "-G's newest raw hit, skip the value +# comparison entirely" and THE WHOLE SUITE STAYED GREEN, including the section +# above - because a linear history of real bumps never produces a commit that +# touches the version LINE without changing the version VALUE. So the docstring's +# central claim ("the VALUE is compared, and that is the only thing that answers +# it") was not pinned by anything, and `-G` alone would have shipped as if it +# were the fix. That is the third distinct shape of habit 4 in this one file. +# +# A cosmetic touch of the version line is the discriminator: `-G '^version: "'` +# matches it, and the value comparison does not. +write(addon4, "config.yaml", 'name: t\nversion: "0.7.0" # pinned deliberately\n') +git(root4, "commit", "-qam", "COSMETIC: annotate the version line, same value") +_cosmetic = git(root4, "rev-parse", "HEAD").stdout.strip() + +check("⭐ A COMMIT THAT TOUCHES THE VERSION LINE WITHOUT CHANGING THE VERSION " + "IS NOT A BUMP. `-G` matches it and a filter-only lookup would name it; " + "only comparing the VALUE against the parent's answers the question the " + "tool is actually asking", + last_version_change(root4) == _last_bump) +check("...and it is emphatically not the cosmetic commit", + last_version_change(root4) != _cosmetic) + +r8 = run_gate(addon4, root4, "0.7.0", opts_file(VALID)) +check("...and because config.yaml IS a runtime path, that cosmetic commit is " + "still counted as something unshipped - conservative, and correct: the " + "Supervisor rebuilds on the version string alone, so a changed file " + "behind an unchanged version is a no-op whatever changed in it", + r8.returncode == 1 and "COSMETIC" in r8.stdout) + + +print("\nthe shipped tree's own version is readable") +check("shipped_version() finds a version", bool(shipped_version(HERE))) + +for d in tmpdirs: + shutil.rmtree(d, ignore_errors=True) + +print() +if fails: + print(f"{len(fails)} FAILED: " + "; ".join(fails)) + sys.exit(1) +print("all checks passed") diff --git a/goodwe_controller/tools/predeploy_check.py b/goodwe_controller/tools/predeploy_check.py new file mode 100644 index 0000000..e5512bb --- /dev/null +++ b/goodwe_controller/tools/predeploy_check.py @@ -0,0 +1,272 @@ +"""PKG-09 - refuse a deploy that would be a no-op, or that the add-on would reject. + + python3 tools/predeploy_check.py --deployed-version 0.7.0 --options live-options.json + +⚠️ RUN IT WHERE THE ADD-ON'S DEPENDENCIES EXIST. `app/validate.py` imports its +siblings and those need aiohttp, so on a bare host the validator half SKIPS - +loudly, and exit 2, never a silent pass. To get both halves: + + docker run --rm -v :/w:ro goodwe-addon-checks:1 \ + python3 /w/goodwe_controller/tools/predeploy_check.py \ + --deployed-version 0.7.0 --options /w/live-options.json + +EXIT CODES. 0 cleared; 1 REFUSED, do not deploy; 2 NOT CLEARED - a check did not +run, and that is not the same as passing. + +TWO FAILURES THIS ANSWERS, BOTH OF WHICH HAVE HAPPENED. + +1. THE SILENT NO-OP. The Supervisor keys the built image by `version:` in + config.yaml. `ha apps update` against an unchanged version does nothing and + REPORTS SUCCESS. PKG-06 recorded this on 2026-09-05 with SIX merges behind + an unbumped version - its own commit message says so, and this line said + "four" through two review rounds; PKG-07 recorded it again on 2026-09-06 with six. Both + times a human noticed. Nothing else was ever going to. + +2. THE REFUSED CONFIG. A config the shipped validator rejects is + `SystemExit(78)` - the add-on does not start. ⚠️ AND THE INGRESS PANEL GOES + WITH IT, because the add-on serving it is not running, so recovery means the + Supervisor UI. SAFETY-39's rule 10 made this reachable by a config that + started fine on the previous version: it refuses an explicit `slew_w` above + SLEW_RAIL_FRAC x the site rail, and 0.7.0's own shipped default of 1000 + exceeds the 555.6 W ceiling on a 5000 W rail. + +⚠️ WHY THIS IS NOT A CI CHECK. The obvious version gate - "a pull request that +touches runtime code must bump the version" - is the WRONG shape for this +project, which deliberately batches many merges into one deploy. It would demand +a bump per pull request, put every branch in conflict over one line of +config.yaml, and still not prevent the actual failure, which happens at DEPLOY +and not at merge. The check belongs where the mistake is made. + +⚠️ WHAT THIS DOES NOT DO. It does not read the live box. The options blob is +reachable only through the Supervisor API, which needs a supervisor token, so +the operator exports it and passes `--options`. Without that flag the validator +half is SKIPPED and says so - a skipped check must never look like a passed one. +""" +import argparse +import json +import os +import re +import subprocess +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +ADDON = os.path.dirname(HERE) +sys.path.insert(0, ADDON) + +# Files that end up in the built image and can change what it DOES. A change to +# any of these needs a new `version:` or the Supervisor will not rebuild. +# CHANGELOG.md, DOCS.md, MUTATION.md, DECISIONS/, test_*.py, tools/ and +# scenarios/ are deliberately absent: they ship in the repo but cannot change +# runtime behaviour, and demanding a bump for a typo fix trains people to bump +# without reading, which is the habit this file exists to prevent. +RUNTIME = ( + "goodwe_controller/app/", + "goodwe_controller/translations/", + "goodwe_controller/config.yaml", + "goodwe_controller/Dockerfile", + "goodwe_controller/run.sh", + "goodwe_controller/requirements.txt", + "goodwe_controller/build.yaml", +) + +CONFIG_PATH = "goodwe_controller/config.yaml" +_VER = re.compile(r'^version:\s*"([^"]+)"', re.M) + + +def shipped_version(tree=ADDON): + with open(os.path.join(tree, "config.yaml"), encoding="utf-8") as fh: + m = _VER.search(fh.read()) + return m.group(1) if m else None + + +def is_runtime(path): + return any(path == p or path.startswith(p) for p in RUNTIME) + + +def _git(*args, repo=None): + return subprocess.run(["git", "-C", repo or os.path.dirname(ADDON), *args], + capture_output=True, text=True) + + +def _version_at(sha, repo=None, path=CONFIG_PATH): + """`version:` as of one commit, or None if the file or the key is absent.""" + r = _git("show", f"{sha}:{path}", repo=repo) + if r.returncode != 0: + return None + m = _VER.search(r.stdout) + return m.group(1) if m else None + + +def last_version_change(repo=None): + """The newest commit whose `version:` VALUE differs from its parent's. + + ⚠️ THIS USED TO BE `git log -S 'version: "'` AND IT WAS WRONG ON EVERY REAL + REPOSITORY. `-S` is the pickaxe: it finds commits that change the COUNT of + a literal string. A bump edits the line in place - `version: "0.7.0"` -> + `version: "0.8.0"` - so the substring still occurs exactly once before and + after, and `-S` never sees it. It found only the commit that first + INTRODUCED the line. + + Measured on this repository: `-S` returned `0a1e61d` (2026-08-23, the + original packaging commit) as "the last bump", so the script reported the + correctly-bumped tree as 87 commits behind an unbumped version - listing + PKG-06's and PKG-07's own bump commits among the offenders. A tool whose + entire purpose is to be trusted before a deploy cried wolf on every run. + + ⚠️ AND `-G` IS NOT THE FIX EITHER, ONLY A BETTER FILTER. `-G'^version: "'` + matches a commit whose diff touches a line with that shape - which includes + a reformat, a quote change, or a comment moved onto the line, none of which + are bumps. So `-G` narrows the candidates and then the VALUE is compared + against the parent's. The question is "did the version change", and that is + the only thing that answers it. + + ⚠️ TWO GIT SHAPES THIS STILL GETS WRONG, NEITHER OF WHICH HAS EVER OCCURRED + IN THIS REPOSITORY - every bump here has been a clean, linear, forward-only + edit. Both were constructed in review rather than found: + + 1. A BUMP RESOLVED INSIDE A MERGE COMMIT IS INVISIBLE. `git log -G -- path` + does not diff merge commits at all without `-m`, so a version line whose + final value is decided by a conflict resolution is never a candidate. + The lookup then names the last ordinary bump, and the merge itself counts + as a runtime change since it - a FALSE REFUSAL of a tree that is in fact + correctly bumped. + 2. REVERTING A BUMP WHILE KEEPING THE WORK MASKS THE WORK. Bump, change + `app/`, then revert only `config.yaml`: the version is back where it + started, the runtime change is still in the tree, and this reports + "nothing to deploy" - a FALSE CLEARANCE, which is the dangerous + direction. + + Card PKG-11 carries both. They are recorded here rather than fixed because + a lookup that handles them costs more than the failure they represent, and + a caveat a reader can check beats a guarantee that is not true. + """ + r = _git("log", "--format=%H", "-G", r'^version: "', "--", CONFIG_PATH, + repo=repo) + if r.returncode != 0: + return None + shas = r.stdout.split() + for sha in shas: # newest first + here = _version_at(sha, repo) + there = _version_at(sha + "^", repo) + if here is not None and here != there: + return sha + return shas[-1] if shas else None + + +def runtime_commits_since_version_change(repo=None): + """Commits touching RUNTIME since `version:` last changed. ([], None) if unknown. + + Returns (commits, last_bump_sha). An empty list with a sha means the tree is + clean; an empty list with None means git could not answer and the caller + must NOT read that as a pass. + """ + last = last_version_change(repo) + if not last: + return [], None + r = _git("log", "--format=%H %s", f"{last}..HEAD", repo=repo) + if r.returncode != 0: + return [], None + out = [] + for line in r.stdout.splitlines(): + sha, _, subject = line.partition(" ") + f = _git("show", "--name-only", "--format=", sha, repo=repo) + if any(is_runtime(p) for p in f.stdout.split()): + out.append((sha[:10], subject)) + return out, last[:10] + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--deployed-version", required=True, + help="the version currently running ON THE BOX") + ap.add_argument("--options", help="path to the live options blob, exported " + "from the Supervisor. Omitted = that half is SKIPPED.") + ap.add_argument("--tree", default=ADDON) + ap.add_argument("--repo", default=None) + a = ap.parse_args() + + problems, skipped = [], [] + shipped = shipped_version(a.tree) + print(f" deployed on the box : {a.deployed_version}") + print(f" shipped in this tree: {shipped}") + + commits, last = runtime_commits_since_version_change(a.repo) + if shipped is None: + problems.append("could not read `version:` out of config.yaml") + elif shipped == a.deployed_version: + if last is None: + skipped.append("could not ask git what changed since the last bump; " + "the no-op check is INCONCLUSIVE, not clean") + if commits: + problems.append( + f"THE DEPLOY WOULD BE A NO-OP. Version is {shipped} on both sides, and " + f"{len(commits)} commit(s) touching runtime files have landed since the " + f"version last changed ({last}). `ha apps update` will report success " + f"and install NOTHING. Bump `version:` first.") + for sha, subj in commits[:12]: + print(f" {sha} {subj[:76]}") + elif last is not None: + print(" no runtime change since the last bump - nothing to deploy") + + if a.options: + # ⚠️ A TOOL RUN IN A HURRY BEFORE A DEPLOY MUST NOT ANSWER WITH A + # TRACEBACK. A missing or malformed export is the likeliest operator + # error here, and it is a REFUSAL, not a skip - the config was not + # checked and the script must not be re-run past it. + opts = None + try: + with open(a.options, encoding="utf-8") as fh: + opts = json.load(fh) + except FileNotFoundError: + problems.append(f"--options file not found: {a.options}") + except (json.JSONDecodeError, OSError, UnicodeDecodeError) as exc: + problems.append(f"--options file could not be read as JSON " + f"({type(exc).__name__}: {exc}); the config was NOT checked") + if a.options and opts is not None: + try: + from app.validate import validate_startup_config + except Exception as exc: # pragma: no cover + skipped.append( + f"could not import the shipped validator ({exc}); the config check " + f"did NOT run. `app/validate.py` imports its siblings, and those need " + f"the add-on's runtime dependencies - so THIS SCRIPT ONLY CHECKS A " + f"CONFIG WHERE THOSE DEPENDENCIES EXIST: inside the add-on container, " + f"or in the CI image. Re-run it as:\n" + f" docker run --rm -v :/w:ro goodwe-addon-checks:1 \\\n" + f" python3 /w/goodwe_controller/tools/predeploy_check.py \\\n" + f" --deployed-version --options /w/") + else: + found = validate_startup_config(opts) + if found: + problems.append( + f"THE LIVE CONFIG IS REFUSED BY THIS VERSION'S VALIDATOR " + f"({len(found)} problem(s)). Deploying it is SystemExit(78): the " + f"add-on does not start, and the ingress panel goes with it because " + f"the add-on serving it is not running. Recovery is the Supervisor UI.") + for p in found: + print(f" - {p}") + else: + print(" live options pass this version's validator") + else: + skipped.append("--options was not given, so the live config was NEVER CHECKED " + "against this version's validator. A config refused at startup is " + "exit 78. Export the options and re-run before deploying.") + + print() + for s in skipped: + print(f" ⚠️ SKIPPED: {s}") + if problems: + for p in problems: + print(f" ❌ {p}") + print("\n REFUSED - do not deploy.") + return 1 + if skipped: + print(" NOT CLEARED - checks above did not run. Deploying now is a decision " + "made without them.") + return 2 + print(" CLEARED for deploy.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())