diff --git a/drb-edge-node/tests/test_host_updater.py b/drb-edge-node/tests/test_host_updater.py index c2006b2..73b92bc 100644 --- a/drb-edge-node/tests/test_host_updater.py +++ b/drb-edge-node/tests/test_host_updater.py @@ -24,21 +24,37 @@ pytestmark = pytest.mark.skipif( ) REPO = "https://git.example.net/logan/node-26.git" -OLD, NEW = "a" * 40, "b" * 40 +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 ]; do shift 2; done +while [ "$1" = -c ] || [ "$1" = -C ]; do shift 2; done case "$1" in - rev-parse) if [ "$2" = HEAD ]; then cat "$STUB/head"; else echo "$STUB_NEW"; fi ;; + rev-parse) + if [ "$2" = HEAD ]; then cat "$STUB/head" + elif [ "$2" = --verify ]; then echo "$STUB_TREE" # tree hash of : + 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" ;; *) exit 0 ;; esac ''' -STUB_DOCKER = '#!/usr/bin/env bash\necho "docker $*" >> "$STUB/calls"\n' +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 @@ -79,6 +95,7 @@ def env(tmp_path): 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"), @@ -94,6 +111,10 @@ 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 @@ -108,7 +129,7 @@ def test_c2_request_updates_once_and_reports(env): r = _run(env) assert r.returncode == 0, r.stderr + r.stdout calls = _calls(env) - assert "docker compose pull" in calls + 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 @@ -126,7 +147,7 @@ def test_mqtt_sentinel_triggers_update_and_is_consumed(env): assert r.returncode == 0, r.stderr assert not sentinel.exists() calls = _calls(env) - assert "docker compose pull" in calls + assert _pulled(calls) assert "evil" not in calls and "rm -rf" not in calls @@ -153,7 +174,7 @@ def test_refuses_when_env_registry_differs(env): (env["inst"] / "configs" / "update-requested").write_text("") r = _run(env) assert r.returncode != 0 - assert "docker compose pull" not in _calls(env) + assert not _pulled(_calls(env)) def test_non_https_repo_in_conf_is_rejected(env): @@ -177,3 +198,42 @@ def test_plain_http_c2_skips_checkin(env): 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() diff --git a/install.sh b/install.sh index d7b7244..3d1f29a 100644 --- a/install.sh +++ b/install.sh @@ -340,10 +340,16 @@ fi # Pin every image to the source just checked out (node-26#19, D3). Written # into .env — fresh or preserved — so later manual `docker compose` runs on # this node use the same pins. -# shellcheck source=scripts/image-tags.sh -. "$INSTALL_DIR/scripts/image-tags.sh" -drb_pin_image_tags "$INSTALL_DIR/.env" "$INSTALL_DIR" "$RESOLVED_SHA" || die "could not write image pins to .env" -ok "images pinned to the source at ${RESOLVED_SHA:0:12} (DRB_TAG_* in .env)" +# A --ref older than #19 has no helper; its compose file hardcodes its tags. +if [ -f "$INSTALL_DIR/scripts/image-tags.sh" ]; then + # shellcheck source=scripts/image-tags.sh + . "$INSTALL_DIR/scripts/image-tags.sh" + drb_pin_image_tags "$INSTALL_DIR/.env" "$INSTALL_DIR" "$RESOLVED_SHA" || die "could not write image pins to .env" + ok "images pinned to the source at ${RESOLVED_SHA:0:12} (DRB_TAG_* in .env)" +else + warn "$REF predates image pinning (node-26#19) — using its compose file's default image tags" + drb_images_present() { return 0; } +fi # ── 5. Enrollment ─────────────────────────────────────────────────────────── # Client half of Server/drb-c2-core/app/routers/enrollment.py. It does NOT diff --git a/scripts/drb-update.sh b/scripts/drb-update.sh index 2c3587f..6237339 100644 --- a/scripts/drb-update.sh +++ b/scripts/drb-update.sh @@ -158,10 +158,12 @@ do_update() { # the git tree hash of its own source directory, so the images always match # the code being checked out. The helper is read from the target commit so # its service list matches the target's compose file. - local pin=0 + # Read into a variable, not `source <(...)`: a failed `git show` must leave + # pin=0 rather than "succeed" with no functions defined. + local pin=0 helper if git cat-file -e "$new:scripts/image-tags.sh" 2>/dev/null; then - # shellcheck disable=SC1090 - source <(git show "$new:scripts/image-tags.sh") && pin=1 + helper="$(git show "$new:scripts/image-tags.sh")" && [ -n "$helper" ] && eval "$helper" \ + && declare -F drb_image_tags drb_images_present drb_pin_image_tags >/dev/null && pin=1 fi [ "$pin" = 1 ] || log "target ${new:0:12} predates image pinning — using its compose file's default tags"