VERSION holds MAJOR.MINOR (1.1), edited by hand. Every push to main is tagged vMAJOR.MINOR.PATCH by the "Tag release" workflow, with PATCH counted as an integer by scripts/next-version.sh. "Promote release" moves the major tag (v1) to a tested release and refuses to move it backwards. drb-update.sh: a newer release on the node's channel is now a reason to update by itself. REF=latest follows the highest vX.Y.Z tag (test nodes); REF=v1 follows the promoted tag (customer nodes). Versions compare per field as integers (1.10.0 > 1.9.3, never string or float order). It never downgrades, and an untagged target still needs a request. After an update it writes DRB_VERSION to .env; the edge node reports it in checkin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
327 lines
12 KiB
Python
327 lines
12 KiB
Python
"""
|
|
node-26#15 — Client/scripts/drb-update.sh, the host-level updater.
|
|
|
|
Runs the real script with stub git/docker/curl on PATH, so what is checked is
|
|
the script's decision logic and its security guards: it only updates when
|
|
asked (MQTT sentinel or a new C2 request), never takes anything but "update
|
|
now" from C2, refuses a mismatched git origin or registry, and keeps node
|
|
credentials off curl's argv.
|
|
"""
|
|
import json
|
|
import os
|
|
import shutil
|
|
import stat
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "drb-update.sh"
|
|
|
|
pytestmark = pytest.mark.skipif(
|
|
not (SCRIPT.exists() and shutil.which("bash") and shutil.which("jq") and shutil.which("flock")),
|
|
reason="needs the repo's scripts/ (absent inside the image) plus bash, jq and flock",
|
|
)
|
|
|
|
REPO = "https://git.example.net/logan/node-26.git"
|
|
OLD, NEW, TREE = "a" * 40, "b" * 40, "c" * 40
|
|
REPO_ROOT = SCRIPT.parents[1]
|
|
|
|
STUB_GIT = r'''#!/usr/bin/env bash
|
|
echo "git $*" >> "$STUB/calls"
|
|
while [ "$1" = -c ] || [ "$1" = -C ]; do shift 2; done
|
|
case "$1" in
|
|
rev-parse)
|
|
if [ "$2" = HEAD ]; then cat "$STUB/head"
|
|
elif [ "$2" = --verify ]; then echo "$STUB_TREE" # tree hash of <commit>:<dir>
|
|
else echo "$STUB_NEW"; fi ;;
|
|
remote) echo "$STUB_ORIGIN" ;;
|
|
fetch) exit 0 ;;
|
|
show-ref) [ "$4" = "refs/tags/v1" ] ;;
|
|
cat-file) [ -z "${STUB_NO_PIN:-}" ] ;; # target has scripts/image-tags.sh
|
|
show) case "$2" in
|
|
*:scripts/image-tags.sh) cat "$STUB_REPO/scripts/image-tags.sh" ;;
|
|
*:docker-compose.yml) cat "$STUB_REPO/docker-compose.yml" ;;
|
|
esac ;;
|
|
checkout) echo "${@: -1}" > "$STUB/head" ;;
|
|
tag)
|
|
if [ "$2" = --points-at ]; then
|
|
if [ "$3" = HEAD ]; then cat "$STUB/tags_head" 2>/dev/null; else cat "$STUB/tags_new" 2>/dev/null; fi
|
|
else cat "$STUB/tags_all" 2>/dev/null; fi
|
|
exit 0 ;;
|
|
*) exit 0 ;;
|
|
esac
|
|
'''
|
|
STUB_DOCKER = r'''#!/usr/bin/env bash
|
|
echo "docker $*" >> "$STUB/calls"
|
|
case "$*" in
|
|
*"config --images"*) echo "reg.example/edge-node:$STUB_PULLED_TAG" ;;
|
|
"image inspect"*) [ -z "${STUB_MISSING_IMAGE:-}" ] || exit 1 ;;
|
|
esac
|
|
exit 0
|
|
'''
|
|
STUB_CURL = r'''#!/usr/bin/env bash
|
|
echo "curl $*" >> "$STUB/calls"
|
|
for a in "$@"; do case "$a" in @*) cat "${a#@}" >> "$STUB/headers" ;; esac; done
|
|
cat "$STUB/response"
|
|
'''
|
|
|
|
|
|
def _exe(path: Path, body: str):
|
|
path.write_text(body)
|
|
path.chmod(path.stat().st_mode | stat.S_IEXEC)
|
|
|
|
|
|
@pytest.fixture
|
|
def env(tmp_path):
|
|
stub = tmp_path / "stub"
|
|
bindir = stub / "bin"
|
|
bindir.mkdir(parents=True)
|
|
_exe(bindir / "git", STUB_GIT)
|
|
_exe(bindir / "docker", STUB_DOCKER)
|
|
_exe(bindir / "curl", STUB_CURL)
|
|
(stub / "head").write_text(OLD + "\n")
|
|
(stub / "calls").write_text("")
|
|
(stub / "response").write_text(json.dumps({"update_requested_at": None}))
|
|
|
|
inst = tmp_path / "node"
|
|
(inst / ".git").mkdir(parents=True)
|
|
(inst / "configs").mkdir()
|
|
(inst / ".env").write_text(
|
|
"NODE_ID=node-7\nC2_URL=https://api.example.net\nIMAGE_REGISTRY=git.example.net\n"
|
|
)
|
|
(inst / "configs" / "credentials.json").write_text(json.dumps({"api_key": "k" * 64}))
|
|
(inst / "configs" / "pickup_secret").write_text("p" * 48)
|
|
|
|
conf = tmp_path / "updater.conf"
|
|
conf.write_text(f"INSTALL_DIR={inst}\nREPO_URL={REPO}\nREF=v1\nREGISTRY=git.example.net\nBUILD=0\n")
|
|
|
|
e = dict(os.environ)
|
|
e.update({
|
|
"PATH": f"{bindir}:{os.environ['PATH']}",
|
|
"STUB": str(stub), "STUB_NEW": NEW, "STUB_ORIGIN": REPO,
|
|
"STUB_TREE": TREE, "STUB_REPO": str(REPO_ROOT), "STUB_PULLED_TAG": f"src-{TREE}",
|
|
"DRB_UPDATER_CONF": str(conf),
|
|
"DRB_UPDATER_STATE": str(tmp_path / "state"),
|
|
"DRB_UPDATER_LOCK": str(tmp_path / "lock"),
|
|
})
|
|
return {"env": e, "stub": stub, "inst": inst, "conf": conf, "state": tmp_path / "state"}
|
|
|
|
|
|
def _run(env):
|
|
return subprocess.run(["bash", str(SCRIPT)], env=env["env"], capture_output=True, text=True, timeout=30)
|
|
|
|
|
|
def _calls(env):
|
|
return (env["stub"] / "calls").read_text()
|
|
|
|
|
|
def _pulled(calls: str) -> bool:
|
|
return any(line.startswith("docker compose ") and line.endswith(" pull") for line in calls.splitlines())
|
|
|
|
|
|
def test_no_request_means_checkin_only(env):
|
|
r = _run(env)
|
|
assert r.returncode == 0, r.stderr
|
|
calls = _calls(env)
|
|
assert "update-check" in calls
|
|
assert "docker" not in calls
|
|
assert "checkout" not in calls
|
|
|
|
|
|
def test_c2_request_updates_once_and_reports(env):
|
|
(env["stub"] / "response").write_text(json.dumps({"update_requested_at": "2026-10-04T12:00:00+00:00"}))
|
|
r = _run(env)
|
|
assert r.returncode == 0, r.stderr + r.stdout
|
|
calls = _calls(env)
|
|
assert _pulled(calls)
|
|
assert "docker compose up -d --no-build --force-recreate --remove-orphans" in calls
|
|
assert (env["stub"] / "head").read_text().strip() == NEW
|
|
assert '"applied_request_at":"2026-10-04T12:00:00+00:00"' in calls
|
|
assert '"result":"ok"' in calls
|
|
# Same request again: nothing to do.
|
|
(env["stub"] / "calls").write_text("")
|
|
assert _run(env).returncode == 0
|
|
assert "docker" not in _calls(env)
|
|
|
|
|
|
def test_mqtt_sentinel_triggers_update_and_is_consumed(env):
|
|
sentinel = env["inst"] / "configs" / "update-requested"
|
|
sentinel.write_text('{"ref": "evil", "cmd": "rm -rf /"}')
|
|
r = _run(env)
|
|
assert r.returncode == 0, r.stderr
|
|
assert not sentinel.exists()
|
|
calls = _calls(env)
|
|
assert _pulled(calls)
|
|
assert "evil" not in calls and "rm -rf" not in calls
|
|
|
|
|
|
def test_update_prunes_build_cache_and_stale_images_after_up(env):
|
|
# node-26#41: the build cache was never pruned and filled node-002's disk.
|
|
(env["stub"] / "response").write_text(json.dumps({"update_requested_at": "2026-10-04T12:00:00+00:00"}))
|
|
assert _run(env).returncode == 0
|
|
lines = _calls(env).splitlines()
|
|
up = next(i for i, ln in enumerate(lines) if ln.startswith("docker compose up -d"))
|
|
builder = next(i for i, ln in enumerate(lines) if ln.startswith("docker builder prune -af --filter until="))
|
|
images = next(i for i, ln in enumerate(lines) if ln.startswith("docker image prune -af --filter until="))
|
|
assert up < builder and up < images # only after the new containers are running
|
|
|
|
|
|
def test_c2_response_is_never_used_as_a_ref_or_command(env):
|
|
(env["stub"] / "response").write_text(json.dumps({"update_requested_at": "$(touch /tmp/pwned); main"}))
|
|
r = _run(env)
|
|
assert r.returncode == 0
|
|
assert "docker" not in _calls(env) # malformed request is ignored, not executed
|
|
|
|
|
|
def test_refuses_when_git_origin_differs(env):
|
|
env["env"]["STUB_ORIGIN"] = "https://attacker.example/node-26.git"
|
|
(env["inst"] / "configs" / "update-requested").write_text("")
|
|
r = _run(env)
|
|
assert r.returncode != 0
|
|
assert "refusing" in r.stderr
|
|
assert "docker" not in _calls(env)
|
|
|
|
|
|
def test_refuses_when_env_registry_differs(env):
|
|
(env["inst"] / ".env").write_text(
|
|
"NODE_ID=node-7\nC2_URL=https://api.example.net\nIMAGE_REGISTRY=evil.example\n"
|
|
)
|
|
(env["inst"] / "configs" / "update-requested").write_text("")
|
|
r = _run(env)
|
|
assert r.returncode != 0
|
|
assert not _pulled(_calls(env))
|
|
|
|
|
|
def test_non_https_repo_in_conf_is_rejected(env):
|
|
env["conf"].write_text(env["conf"].read_text().replace("https://", "http://"))
|
|
r = _run(env)
|
|
assert r.returncode != 0
|
|
assert "git" not in _calls(env)
|
|
|
|
|
|
def test_credentials_go_in_a_header_file_never_argv(env):
|
|
_run(env)
|
|
calls = _calls(env)
|
|
assert "k" * 64 not in calls and "p" * 48 not in calls
|
|
headers = (env["stub"] / "headers").read_text()
|
|
assert f"Authorization: Bearer {'k' * 64}" in headers
|
|
assert f"X-Pickup-Secret: {'p' * 48}" in headers
|
|
|
|
|
|
def test_plain_http_c2_skips_checkin(env):
|
|
(env["inst"] / ".env").write_text("NODE_ID=node-7\nC2_URL=http://api.example.net\nIMAGE_REGISTRY=git.example.net\n")
|
|
r = _run(env)
|
|
assert r.returncode == 0
|
|
assert "curl" not in _calls(env)
|
|
|
|
|
|
# ── node-26#19: images pinned to the target's source tree ─────────────────
|
|
|
|
def test_pins_images_and_pulls_before_checkout(env):
|
|
(env["inst"] / "configs" / "update-requested").write_text("")
|
|
r = _run(env)
|
|
assert r.returncode == 0, r.stderr + r.stdout
|
|
lines = _calls(env).splitlines()
|
|
pull = next(i for i, ln in enumerate(lines) if ln.startswith("docker compose ") and ln.endswith(" pull"))
|
|
checkout = next(i for i, ln in enumerate(lines) if ln.startswith("git") and " checkout " in ln)
|
|
assert pull < checkout, "images must be pulled before the new code is checked out"
|
|
dotenv = (env["inst"] / ".env").read_text()
|
|
for var in ("DRB_TAG_EDGE_NODE", "DRB_TAG_OP25", "DRB_TAG_ICECAST", "DRB_TAG_SECONDARY_SDR"):
|
|
assert f"{var}=src-{TREE}\n" in dotenv
|
|
assert dotenv.startswith("NODE_ID=node-7\n") # existing lines kept
|
|
assert not (env["inst"] / ".drb-update-compose.yml").exists()
|
|
|
|
|
|
def test_missing_pinned_image_changes_nothing(env):
|
|
env["env"]["STUB_MISSING_IMAGE"] = "1"
|
|
(env["inst"] / "configs" / "update-requested").write_text("")
|
|
r = _run(env)
|
|
assert r.returncode != 0
|
|
assert (env["stub"] / "head").read_text().strip() == OLD
|
|
calls = _calls(env)
|
|
assert " checkout " not in calls
|
|
assert "up -d" not in calls
|
|
assert "DRB_TAG_" not in (env["inst"] / ".env").read_text()
|
|
|
|
|
|
def test_target_without_pinning_still_updates(env):
|
|
env["env"]["STUB_NO_PIN"] = "1"
|
|
(env["inst"] / "configs" / "update-requested").write_text("")
|
|
r = _run(env)
|
|
assert r.returncode == 0, r.stderr + r.stdout
|
|
assert (env["stub"] / "head").read_text().strip() == NEW
|
|
assert _pulled(_calls(env))
|
|
assert "DRB_TAG_" not in (env["inst"] / ".env").read_text()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Releases (node-26#49): a newer version on the node's channel is a reason to
|
|
# update by itself, and versions compare per field as integers.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _channel(env, ref, all_tags, target_tags, head_tags):
|
|
env["conf"].write_text(env["conf"].read_text().replace("REF=v1", f"REF={ref}"))
|
|
(env["stub"] / "tags_all").write_text("\n".join(all_tags) + "\n")
|
|
(env["stub"] / "tags_new").write_text("\n".join(target_tags) + "\n")
|
|
(env["stub"] / "tags_head").write_text("\n".join(head_tags) + "\n")
|
|
|
|
|
|
def test_latest_channel_installs_a_newer_release_unasked(env):
|
|
# 1.1.10 is newer than 1.1.9: compared as integers, not strings or floats.
|
|
_channel(env, "latest", ["v1", "v1.1.9", "v1.1.10", "v1.1.2"], ["v1.1.10"], ["v1.1.9"])
|
|
r = _run(env)
|
|
assert r.returncode == 0, r.stderr + r.stdout
|
|
assert "release 1.1.10 on latest (installed 1.1.9)" in r.stdout
|
|
assert (env["stub"] / "head").read_text().strip() == NEW
|
|
assert "DRB_VERSION=1.1.10" in (env["inst"] / ".env").read_text()
|
|
|
|
|
|
def test_never_downgrades(env):
|
|
_channel(env, "latest", ["v1.1.9", "v1.2.0"], ["v1.1.9"], ["v1.2.0"])
|
|
r = _run(env)
|
|
assert r.returncode == 0, r.stderr
|
|
assert "docker" not in _calls(env)
|
|
|
|
|
|
def test_same_release_is_not_reinstalled(env):
|
|
_channel(env, "v1", ["v1", "v1.1.3"], ["v1", "v1.1.3"], ["v1", "v1.1.3"])
|
|
assert _run(env).returncode == 0
|
|
assert "docker" not in _calls(env)
|
|
|
|
|
|
def test_promoted_major_tag_installs_on_stable_nodes(env):
|
|
_channel(env, "v1", ["v1", "v1.1.0", "v1.1.4"], ["v1", "v1.1.4"], ["v1.1.0"])
|
|
r = _run(env)
|
|
assert r.returncode == 0, r.stderr + r.stdout
|
|
assert "release 1.1.4 on v1" in r.stdout
|
|
assert (env["stub"] / "head").read_text().strip() == NEW
|
|
|
|
|
|
def test_untagged_target_still_needs_a_request(env):
|
|
"""v1 before its first promotion points at a commit with no release tag."""
|
|
_channel(env, "v1", ["v1"], ["v1"], [])
|
|
assert _run(env).returncode == 0
|
|
assert "docker" not in _calls(env)
|
|
|
|
|
|
def test_next_version_counts_patches_as_integers(tmp_path):
|
|
script = REPO_ROOT / "scripts" / "next-version.sh"
|
|
g = ["git", "-c", "user.email=t@t", "-c", "user.name=t", "-C", str(tmp_path)]
|
|
subprocess.run(["git", "init", "-q", str(tmp_path)], check=True)
|
|
(tmp_path / "VERSION").write_text("1.1\n")
|
|
subprocess.run(g + ["commit", "-q", "--allow-empty", "-m", "a"], check=True)
|
|
|
|
def nxt():
|
|
return subprocess.run(["bash", str(script)], cwd=tmp_path, capture_output=True, text=True,
|
|
check=True).stdout.strip()
|
|
|
|
assert nxt() == "v1.1.0"
|
|
for t in ("v1.1.0", "v1.1.9", "v1.0.30"):
|
|
subprocess.run(g + ["tag", t], check=True)
|
|
assert nxt() == "", "a commit that already has a release gets no second one"
|
|
subprocess.run(g + ["commit", "-q", "--allow-empty", "-m", "b"], check=True)
|
|
assert nxt() == "v1.1.10"
|
|
(tmp_path / "VERSION").write_text("1.2\n")
|
|
assert nxt() == "v1.2.0"
|