Fix updater tests for image pinning; harden helper load (node-26#19)
- test_host_updater: stub git/docker now model cat-file/show/rev-parse --verify and compose config/image inspect. The two update-path tests were failing on Linux CI (skipped locally on macOS, no flock). Adds tests for pull-before-checkout + .env pins, a missing pinned image leaving the node unchanged, and a pre-pinning target still updating. - drb-update.sh: load image-tags.sh via a variable + eval and confirm the functions exist; `source <(git show ...)` "succeeded" with nothing defined when git show failed, wedging every later update. - install.sh: skip pinning (warn) when the checked-out ref predates scripts/image-tags.sh instead of dying on the missing file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
6c8289f3f3
commit
e7b4aef142
@@ -24,21 +24,37 @@ pytestmark = pytest.mark.skipif(
|
|||||||
)
|
)
|
||||||
|
|
||||||
REPO = "https://git.example.net/logan/node-26.git"
|
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
|
STUB_GIT = r'''#!/usr/bin/env bash
|
||||||
echo "git $*" >> "$STUB/calls"
|
echo "git $*" >> "$STUB/calls"
|
||||||
while [ "$1" = -c ]; do shift 2; done
|
while [ "$1" = -c ] || [ "$1" = -C ]; do shift 2; done
|
||||||
case "$1" in
|
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 <commit>:<dir>
|
||||||
|
else echo "$STUB_NEW"; fi ;;
|
||||||
remote) echo "$STUB_ORIGIN" ;;
|
remote) echo "$STUB_ORIGIN" ;;
|
||||||
fetch) exit 0 ;;
|
fetch) exit 0 ;;
|
||||||
show-ref) [ "$4" = "refs/tags/v1" ] ;;
|
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" ;;
|
checkout) echo "${@: -1}" > "$STUB/head" ;;
|
||||||
*) exit 0 ;;
|
*) exit 0 ;;
|
||||||
esac
|
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
|
STUB_CURL = r'''#!/usr/bin/env bash
|
||||||
echo "curl $*" >> "$STUB/calls"
|
echo "curl $*" >> "$STUB/calls"
|
||||||
for a in "$@"; do case "$a" in @*) cat "${a#@}" >> "$STUB/headers" ;; esac; done
|
for a in "$@"; do case "$a" in @*) cat "${a#@}" >> "$STUB/headers" ;; esac; done
|
||||||
@@ -79,6 +95,7 @@ def env(tmp_path):
|
|||||||
e.update({
|
e.update({
|
||||||
"PATH": f"{bindir}:{os.environ['PATH']}",
|
"PATH": f"{bindir}:{os.environ['PATH']}",
|
||||||
"STUB": str(stub), "STUB_NEW": NEW, "STUB_ORIGIN": REPO,
|
"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_CONF": str(conf),
|
||||||
"DRB_UPDATER_STATE": str(tmp_path / "state"),
|
"DRB_UPDATER_STATE": str(tmp_path / "state"),
|
||||||
"DRB_UPDATER_LOCK": str(tmp_path / "lock"),
|
"DRB_UPDATER_LOCK": str(tmp_path / "lock"),
|
||||||
@@ -94,6 +111,10 @@ def _calls(env):
|
|||||||
return (env["stub"] / "calls").read_text()
|
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):
|
def test_no_request_means_checkin_only(env):
|
||||||
r = _run(env)
|
r = _run(env)
|
||||||
assert r.returncode == 0, r.stderr
|
assert r.returncode == 0, r.stderr
|
||||||
@@ -108,7 +129,7 @@ def test_c2_request_updates_once_and_reports(env):
|
|||||||
r = _run(env)
|
r = _run(env)
|
||||||
assert r.returncode == 0, r.stderr + r.stdout
|
assert r.returncode == 0, r.stderr + r.stdout
|
||||||
calls = _calls(env)
|
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 "docker compose up -d --no-build --force-recreate --remove-orphans" in calls
|
||||||
assert (env["stub"] / "head").read_text().strip() == NEW
|
assert (env["stub"] / "head").read_text().strip() == NEW
|
||||||
assert '"applied_request_at":"2026-10-04T12:00:00+00:00"' in calls
|
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 r.returncode == 0, r.stderr
|
||||||
assert not sentinel.exists()
|
assert not sentinel.exists()
|
||||||
calls = _calls(env)
|
calls = _calls(env)
|
||||||
assert "docker compose pull" in calls
|
assert _pulled(calls)
|
||||||
assert "evil" not in calls and "rm -rf" not in 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("")
|
(env["inst"] / "configs" / "update-requested").write_text("")
|
||||||
r = _run(env)
|
r = _run(env)
|
||||||
assert r.returncode != 0
|
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):
|
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)
|
r = _run(env)
|
||||||
assert r.returncode == 0
|
assert r.returncode == 0
|
||||||
assert "curl" not in _calls(env)
|
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()
|
||||||
|
|||||||
+10
-4
@@ -340,10 +340,16 @@ fi
|
|||||||
# Pin every image to the source just checked out (node-26#19, D3). Written
|
# 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
|
# into .env — fresh or preserved — so later manual `docker compose` runs on
|
||||||
# this node use the same pins.
|
# this node use the same pins.
|
||||||
# shellcheck source=scripts/image-tags.sh
|
# A --ref older than #19 has no helper; its compose file hardcodes its tags.
|
||||||
. "$INSTALL_DIR/scripts/image-tags.sh"
|
if [ -f "$INSTALL_DIR/scripts/image-tags.sh" ]; then
|
||||||
drb_pin_image_tags "$INSTALL_DIR/.env" "$INSTALL_DIR" "$RESOLVED_SHA" || die "could not write image pins to .env"
|
# shellcheck source=scripts/image-tags.sh
|
||||||
ok "images pinned to the source at ${RESOLVED_SHA:0:12} (DRB_TAG_* in .env)"
|
. "$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 ───────────────────────────────────────────────────────────
|
# ── 5. Enrollment ───────────────────────────────────────────────────────────
|
||||||
# Client half of Server/drb-c2-core/app/routers/enrollment.py. It does NOT
|
# Client half of Server/drb-c2-core/app/routers/enrollment.py. It does NOT
|
||||||
|
|||||||
@@ -158,10 +158,12 @@ do_update() {
|
|||||||
# the git tree hash of its own source directory, so the images always match
|
# 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
|
# the code being checked out. The helper is read from the target commit so
|
||||||
# its service list matches the target's compose file.
|
# 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
|
if git cat-file -e "$new:scripts/image-tags.sh" 2>/dev/null; then
|
||||||
# shellcheck disable=SC1090
|
helper="$(git show "$new:scripts/image-tags.sh")" && [ -n "$helper" ] && eval "$helper" \
|
||||||
source <(git show "$new:scripts/image-tags.sh") && pin=1
|
&& declare -F drb_image_tags drb_images_present drb_pin_image_tags >/dev/null && pin=1
|
||||||
fi
|
fi
|
||||||
[ "$pin" = 1 ] || log "target ${new:0:12} predates image pinning — using its compose file's default tags"
|
[ "$pin" = 1 ] || log "target ${new:0:12} predates image pinning — using its compose file's default tags"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user