From f0e2f1ee4dddf5ff4c92cd15fe45d23be5f81ffb Mon Sep 17 00:00:00 2001 From: Johnny Murray Date: Wed, 2 Sep 2026 19:49:32 +0100 Subject: [PATCH 01/18] Prepare AxeBC2 Core 31 DEV migration --- .../workflows/validate-axebc2-core31-dev.yml | 32 +++ scripts/validate-axebc2-core31-dev.py | 51 +++++ tests/test_axebc2_core31_init.py | 156 +++++++++++++ willitmod-dev-bc2/data/init/init.sh | 205 ++++++++++++++++++ .../data/templates/bitcoinII.conf.template | 4 +- willitmod-dev-bc2/docker-compose.yml | 115 +++------- willitmod-dev-bc2/umbrel-app.yml | 14 +- 7 files changed, 483 insertions(+), 94 deletions(-) create mode 100644 .github/workflows/validate-axebc2-core31-dev.yml create mode 100644 scripts/validate-axebc2-core31-dev.py create mode 100644 tests/test_axebc2_core31_init.py create mode 100644 willitmod-dev-bc2/data/init/init.sh diff --git a/.github/workflows/validate-axebc2-core31-dev.yml b/.github/workflows/validate-axebc2-core31-dev.yml new file mode 100644 index 0000000..c928f18 --- /dev/null +++ b/.github/workflows/validate-axebc2-core31-dev.yml @@ -0,0 +1,32 @@ +name: Validate AxeBC2 Core 31 DEV metadata + +on: + pull_request: + paths: + - "willitmod-dev-bc2/**" + - "tests/test_axebc2_core31_init.py" + - "scripts/validate-axebc2-core31-dev.py" + - ".github/workflows/validate-axebc2-core31-dev.yml" + push: + branches: [main] + paths: + - "willitmod-dev-bc2/**" + - "tests/test_axebc2_core31_init.py" + - "scripts/validate-axebc2-core31-dev.py" + - ".github/workflows/validate-axebc2-core31-dev.yml" + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Check out the store + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Install test dependencies + run: sudo apt-get update && sudo apt-get install --yes gettext-base jq + + - name: Validate metadata and migration initialization + run: python3 scripts/validate-axebc2-core31-dev.py diff --git a/scripts/validate-axebc2-core31-dev.py b/scripts/validate-axebc2-core31-dev.py new file mode 100644 index 0000000..026c784 --- /dev/null +++ b/scripts/validate-axebc2-core31-dev.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +from pathlib import Path +import re +import subprocess +import sys +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +APP = ROOT / "willitmod-dev-bc2" + + +def require(condition, message): + if not condition: + raise SystemExit(message) + + +compose = (APP / "docker-compose.yml").read_text(encoding="utf-8") +manifest = (APP / "umbrel-app.yml").read_text(encoding="utf-8") +node_config = (APP / "data/templates/bitcoinII.conf.template").read_text(encoding="utf-8") + +require('version: "0.1.10-dev"' in manifest, "manifest must be 0.1.10-dev") +require("Requires 5tratumOS 0.7.11" in manifest, "OS prerequisite must be disclosed") +require('"2345:3333/tcp"' in compose, "Stratum host port 2345 must be retained") +require("SUPPORT_CHECKIN_ENABLED: \"false\"" in compose, "telemetry must default off") +require("create_host_path: false" in compose, "build metadata bind must fail closed") +require("/etc/5tratumos/build.json" in compose, "build metadata must be mounted") +require( + ".5tratumos-rollback-policy.json" in (APP / "data/init/init.sh").read_text(encoding="utf-8"), + "init must use the policy filename consumed by AxeBC2 and 5tratumOS", +) +require( + "alpine:3.22.1@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1" + in compose, + "init image must be pinned", +) +require( + "ghcr.io/willitmod/docker-ckpool-solo:590fb2a@sha256:8a9a7f10c8138d0f55533132ee7710a06715a42a49f75efb39be3350ada4fa6e" + in compose, + "CKPool image must retain its exact pin", +) +require("natpmp=0" in node_config and "upnp=1" not in node_config, "NAT-PMP must be off") +require(not re.search(r'^\s+-\s+"?8338:', compose, re.MULTILINE), "P2P must not be published") + +for placeholder in ("CORE31_CANDIDATE_DIGEST_REQUIRED", "APP_CANDIDATE_DIGEST_REQUIRED"): + require(placeholder in compose, f"pending digest sentinel is missing: {placeholder}") + +subprocess.run(["sh", "-n", str(APP / "data/init/init.sh")], check=True) +suite = unittest.defaultTestLoader.discover(str(ROOT / "tests"), pattern="test_axebc2_*.py") +result = unittest.TextTestRunner(verbosity=2).run(suite) +sys.exit(0 if result.wasSuccessful() else 1) diff --git a/tests/test_axebc2_core31_init.py b/tests/test_axebc2_core31_init.py new file mode 100644 index 0000000..3134ff8 --- /dev/null +++ b/tests/test_axebc2_core31_init.py @@ -0,0 +1,156 @@ +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +INIT = ROOT / "willitmod-dev-bc2/data/init/init.sh" +TEMPLATES = ROOT / "willitmod-dev-bc2/data/templates" + + +class AxeBC2InitTests(unittest.TestCase): + def setUp(self): + self.tmp = Path(tempfile.mkdtemp(prefix="axebc2-init-")) + self.data = self.tmp / "data" + self.appdata = self.tmp / "appdata" + self.data.mkdir() + self.appdata.mkdir() + self.build = self.tmp / "build.json" + + def tearDown(self): + shutil.rmtree(self.tmp) + + def run_init(self, tag="0.7.11", expect=0): + self.build.write_text(json.dumps({"tag": tag}), encoding="utf-8") + env = os.environ.copy() + env.update( + { + "AXEBC2_DATA_DIR": str(self.data), + "AXEBC2_APPDATA_DIR": str(self.appdata), + "AXEBC2_BUILD_FILE": str(self.build), + "AXEBC2_TEMPLATES_DIR": str(TEMPLATES), + "AXEBC2_TEST_SKIP_CHOWN": "true", + "APPS_SUBNET": "10.0.0.0/16", + "RPC_USER": "btc2", + "RPC_PASSWORD": "test-only", + "BTC2_RPC_PORT": "8337", + "BTC2_P2P_PORT": "8338", + "BTC2_ZMQ_HASHBLOCK_PORT": "28336", + "PAYOUT_ADDRESS": "CHANGEME_BTC2_PAYOUT_ADDRESS", + } + ) + result = subprocess.run( + ["sh", str(INIT)], env=env, text=True, capture_output=True, check=False + ) + self.assertEqual(result.returncode, expect, result.stderr) + return result + + def test_policy_and_reindex_requirement_exist_without_rpc(self): + (self.data / "node/blocks").mkdir(parents=True) + self.run_init() + policy = json.loads( + (self.data / ".5tratumos-rollback-policy.json").read_text(encoding="utf-8") + ) + self.assertEqual(policy["minimum_base_version"], "0.1.10") + self.assertEqual(policy["minimum_5tratumos_version"], "0.7.11") + marker = json.loads( + (self.data / "node/.core31-full-reindex-required.json").read_text( + encoding="utf-8" + ) + ) + self.assertEqual(marker["migration"], "bitcoinii-shockwave-core31-full-reindex") + self.assertEqual(marker["minimum_core_major"], 31) + self.assertEqual(marker["activation_height"], 57750) + + def test_old_os_fails_before_data_mutation(self): + sentinel = self.data / "unchanged" + sentinel.write_text("original", encoding="utf-8") + self.run_init(tag="0.7.10", expect=78) + self.assertEqual(sentinel.read_text(encoding="utf-8"), "original") + self.assertEqual(sorted(p.name for p in self.data.iterdir()), ["unchanged"]) + self.assertEqual(list(self.appdata.iterdir()), []) + + def test_malformed_policy_fails_closed(self): + policy = self.data / ".5tratumos-rollback-policy.json" + policy.write_text('{"schema":1,"app_id":"wrong"}', encoding="utf-8") + self.run_init(expect=78) + self.assertEqual(policy.read_text(encoding="utf-8"), '{"schema":1,"app_id":"wrong"}') + self.assertFalse((self.data / "node").exists()) + + def test_stricter_floors_are_preserved(self): + policy = self.data / ".5tratumos-rollback-policy.json" + policy.write_text( + json.dumps( + { + "schema": 1, + "app_id": "axebc2", + "minimum_base_version": "0.2.3", + "minimum_5tratumos_version": "0.8.1", + "reason": "future stricter policy", + "recorded_at_height": 60000, + } + ), + encoding="utf-8", + ) + self.run_init() + updated = json.loads(policy.read_text(encoding="utf-8")) + self.assertEqual(updated["minimum_base_version"], "0.2.3") + self.assertEqual(updated["minimum_5tratumos_version"], "0.8.1") + self.assertEqual(updated["recorded_at_height"], 60000) + + def test_valid_completion_prevents_required_marker(self): + node = self.data / "node" + (node / "chainstate").mkdir(parents=True) + (node / ".core31-full-reindex-complete.json").write_text( + json.dumps( + { + "schema": 1, + "migration": "bitcoinii-shockwave-core31-full-reindex", + "minimum_core_major": 31, + "activation_height": 57750, + "completed_at": "2026-09-02T00:00:00Z", + "validated_height": 57752, + "best_block_hash": "0" * 64, + "core_version": 310100, + "checkpoint_height": 57752, + "checkpoint_hash": "000000000000000013ceffe797280c57f75a5b9f1d9e70c3503584058c322576", + "validated_chainwork": "0000000000000000000000000000000000000000000000959028194ff1139272", + } + ), + encoding="utf-8", + ) + self.run_init() + self.assertFalse((node / ".core31-full-reindex-required.json").exists()) + + def test_existing_upnp_configuration_is_disabled(self): + node = self.data / "node" + node.mkdir() + config = node / "bitcoinII.conf" + config.write_text("server=1\nupnp=1\nnatpmp=1\n", encoding="utf-8") + self.run_init() + updated = config.read_text(encoding="utf-8") + self.assertNotIn("upnp=", updated) + self.assertEqual(updated.count("natpmp=0"), 1) + + def test_missing_or_malformed_build_metadata_fails_closed(self): + self.build.write_text("not-json", encoding="utf-8") + env = os.environ.copy() + env.update( + { + "AXEBC2_DATA_DIR": str(self.data), + "AXEBC2_APPDATA_DIR": str(self.appdata), + "AXEBC2_BUILD_FILE": str(self.build), + "AXEBC2_TEST_SKIP_CHOWN": "true", + } + ) + result = subprocess.run(["sh", str(INIT)], env=env, capture_output=True) + self.assertEqual(result.returncode, 78) + self.assertEqual(list(self.data.iterdir()), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/willitmod-dev-bc2/data/init/init.sh b/willitmod-dev-bc2/data/init/init.sh new file mode 100644 index 0000000..496ae8b --- /dev/null +++ b/willitmod-dev-bc2/data/init/init.sh @@ -0,0 +1,205 @@ +#!/bin/sh +set -eu + +data_dir="${AXEBC2_DATA_DIR:-/data}" +appdata_dir="${AXEBC2_APPDATA_DIR:-/appdata}" +build_file="${AXEBC2_BUILD_FILE:-/etc/5tratumos/build.json}" +templates_dir="${AXEBC2_TEMPLATES_DIR:-${data_dir}/templates}" +policy_file="${data_dir}/.5tratumos-rollback-policy.json" +node_dir="${data_dir}/node" +required_marker="${node_dir}/.core31-full-reindex-required.json" +complete_marker="${node_dir}/.core31-full-reindex-complete.json" +minimum_os="0.7.11" +minimum_app="0.1.10" +migration="bitcoinii-shockwave-core31-full-reindex" + +fail() { + echo "[axebc2-init] $*" >&2 + exit 78 +} + +if ! command -v jq >/dev/null 2>&1 || ! command -v envsubst >/dev/null 2>&1; then + apk add --no-cache envsubst jq >/dev/null +fi + +version_normalize() { + printf '%s' "$1" | sed -nE 's/^v?([0-9]+(\.[0-9]+){1,3})([-+][0-9A-Za-z][0-9A-Za-z.-]*)?$/\1/p' +} + +version_ge() { + lhs="$(version_normalize "$1")" + rhs="$(version_normalize "$2")" + [ -n "$lhs" ] && [ -n "$rhs" ] || return 1 + component=1 + while [ "$component" -le 4 ]; do + left="$(printf '%s' "$lhs" | cut -d. -f"$component")" + right="$(printf '%s' "$rhs" | cut -d. -f"$component")" + [ "$left" != "$lhs" ] || [ "$component" -eq 1 ] || left=0 + [ "$right" != "$rhs" ] || [ "$component" -eq 1 ] || right=0 + left="$(printf '%s' "${left:-0}" | sed 's/^0*//')"; left="${left:-0}" + right="$(printf '%s' "${right:-0}" | sed 's/^0*//')"; right="${right:-0}" + if [ "${#left}" -gt "${#right}" ]; then return 0; fi + if [ "${#left}" -lt "${#right}" ]; then return 1; fi + if [ "$left" != "$right" ]; then + highest="$(printf '%s\n%s\n' "$left" "$right" | LC_ALL=C sort | tail -n 1)" + [ "$highest" = "$left" ] + return + fi + component=$((component + 1)) + done + return 0 +} + +# This check deliberately precedes every write under /data or /appdata. +[ -r "$build_file" ] || fail "5tratumOS build metadata is missing or unreadable" +installed_os="$(jq -er '.tag | select(type == "string" and length > 0)' "$build_file" 2>/dev/null)" || + fail "5tratumOS build metadata has no valid tag" +version_ge "$installed_os" "$minimum_os" || + fail "5tratumOS ${installed_os} is unsupported; upgrade to ${minimum_os} or newer first" + +atomic_json_write() { + destination="$1" + payload="$2" + parent="$(dirname "$destination")" + [ -d "$parent" ] || mkdir -p "$parent" + temporary="${destination}.tmp.$$" + trap 'rm -f "$temporary"' EXIT HUP INT TERM + umask 077 + printf '%s\n' "$payload" >"$temporary" || fail "cannot write ${destination}" + chmod 600 "$temporary" || fail "cannot protect ${destination}" + if [ "${AXEBC2_TEST_SKIP_CHOWN:-false}" != "true" ]; then + chown 1000:1000 "$temporary" || fail "cannot assign ${destination} to the app user" + fi + mv "$temporary" "$destination" || fail "cannot install ${destination}" + trap - EXIT HUP INT TERM +} + +policy_app="$minimum_app" +policy_os="$minimum_os" +policy_height=57750 +if [ -e "$policy_file" ]; then + [ -r "$policy_file" ] || fail "existing release policy is unreadable" + jq -e ' + type == "object" and .schema == 1 and .app_id == "axebc2" and + (.minimum_base_version | type == "string" and test("^v?[0-9]+(\\.[0-9]+){1,3}([-+][0-9A-Za-z][0-9A-Za-z.-]*)?$")) and + (.minimum_5tratumos_version | type == "string" and test("^v?[0-9]+(\\.[0-9]+){1,3}([-+][0-9A-Za-z][0-9A-Za-z.-]*)?$")) and + (.reason | type == "string" and length > 0) and + (.recorded_at_height | type == "number" and floor == . and . >= 0) + ' "$policy_file" >/dev/null 2>&1 || fail "existing release policy is malformed" + existing_app="$(jq -r '.minimum_base_version' "$policy_file")" + existing_os="$(jq -r '.minimum_5tratumos_version' "$policy_file")" + existing_height="$(jq -r '.recorded_at_height' "$policy_file")" + if version_ge "$existing_app" "$policy_app"; then policy_app="$existing_app"; fi + if version_ge "$existing_os" "$policy_os"; then policy_os="$existing_os"; fi + if [ "$existing_height" -gt "$policy_height" ]; then policy_height="$existing_height"; fi +fi +policy_payload="$(jq -cn \ + --arg app "$policy_app" --arg os "$policy_os" --argjson height "$policy_height" \ + '{schema:1,app_id:"axebc2",minimum_base_version:$app,minimum_5tratumos_version:$os,reason:"ShockWave Core 31 consensus activation requires a non-downgradable app and OS floor",recorded_at_height:$height}')" +atomic_json_write "$policy_file" "$policy_payload" + +validate_migration_marker() { + marker="$1" + jq -e --arg migration "$migration" ' + type == "object" and .schema == 1 and .migration == $migration and + .minimum_core_major == 31 and .activation_height == 57750 + ' "$marker" >/dev/null 2>&1 +} + +validate_complete_marker() { + marker="$1" + validate_migration_marker "$marker" && + jq -e ' + (.completed_at | type == "string" and test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T.*(Z|[+]00:00)$")) and + (.validated_height | type == "number" and floor == . and . >= 57750) and + (.best_block_hash | type == "string" and test("^[0-9a-f]{64}$")) and + (.core_version | type == "number" and floor == . and . >= 310000) and + .checkpoint_height == 57752 and + .checkpoint_hash == "000000000000000013ceffe797280c57f75a5b9f1d9e70c3503584058c322576" and + (.validated_chainwork | type == "string" and test("^[0-9a-f]{64}$") and + . >= "0000000000000000000000000000000000000000000000959028194ff1139272") + ' "$marker" >/dev/null 2>&1 +} + +if [ -e "$complete_marker" ]; then + [ -r "$complete_marker" ] && validate_complete_marker "$complete_marker" || + fail "existing Core 31 completion marker is invalid" +fi +if [ -e "$required_marker" ]; then + [ -r "$required_marker" ] && validate_migration_marker "$required_marker" || + fail "existing Core 31 required marker is invalid" +fi + +if { [ -e "${node_dir}/blocks" ] || [ -e "${node_dir}/chainstate" ]; } && [ ! -e "$complete_marker" ]; then + required_payload='{"schema":1,"migration":"bitcoinii-shockwave-core31-full-reindex","minimum_core_major":31,"activation_height":57750}' + atomic_json_write "$required_marker" "$required_payload" +fi + +# Normal initialization starts only after the OS floor and migration policy exist. +if [ -n "${JWT_SECRET:-}" ]; then + envfile="${appdata_dir}/.env" + tmp="${envfile}.tmp.$$" + if [ -f "$envfile" ]; then grep -v '^JWT_SECRET=' "$envfile" >"$tmp" || true; else : >"$tmp"; fi + printf 'JWT_SECRET=%s\n' "$JWT_SECRET" >>"$tmp" + chmod 600 "$tmp" + chown 1000:1000 "$tmp" 2>/dev/null || true + mv "$tmp" "$envfile" +fi + +mkdir -p "$node_dir" "${data_dir}/pool/config" "${data_dir}/pool/www/pool" "${data_dir}/pool/www/users" +touch "${appdata_dir}/settings.yml" +chown 1000:1000 "${appdata_dir}/settings.yml" 2>/dev/null || true + +if [ ! -f "${node_dir}/bitcoinII.conf" ]; then + envsubst <"${templates_dir}/bitcoinII.conf.template" >"${node_dir}/bitcoinII.conf" + chown -R 1000:1000 "$node_dir" 2>/dev/null || true +fi + +# Existing installs may retain the old upnp=1 setting. Core 31 uses NAT-PMP; +# explicitly disable both forms rather than relying only on the new template. +node_conf="${node_dir}/bitcoinII.conf" +[ -r "$node_conf" ] || fail "BitcoinII configuration is unreadable" +node_conf_tmp="${node_conf}.tmp.$$" +awk ' + !/^[[:space:]]*(upnp|natpmp)[[:space:]]*=/ { print } + END { print "natpmp=0" } +' "$node_conf" >"$node_conf_tmp" || fail "cannot disable automatic P2P port mapping" +chmod 600 "$node_conf_tmp" || fail "cannot protect BitcoinII configuration" +chown 1000:1000 "$node_conf_tmp" 2>/dev/null || true +mv "$node_conf_tmp" "$node_conf" || fail "cannot install BitcoinII configuration" + +ckpool_conf="${data_dir}/pool/config/ckpool.conf" +needs_ckpool_regen=0 +if [ -f "$ckpool_conf" ]; then + grep -qE '"btcd"[[:space:]]*:[[:space:]]*\[' "$ckpool_conf" || needs_ckpool_regen=1 + grep -q '"zmqblock"' "$ckpool_conf" || needs_ckpool_regen=1 +else + needs_ckpool_regen=1 +fi +if [ "$needs_ckpool_regen" -eq 1 ]; then + existing_addr="$(grep -oE '\"btcaddress\"[[:space:]]*:[[:space:]]*\"[^\"]*\"' "$ckpool_conf" 2>/dev/null | head -n 1 | sed -E 's/.*\"btcaddress\"[[:space:]]*:[[:space:]]*\"([^\"]*)\".*/\1/' || true)" + if [ -f "$ckpool_conf" ]; then mv "$ckpool_conf" "${ckpool_conf}.bak.$(date +%s 2>/dev/null || echo 0)"; fi + envsubst <"${templates_dir}/ckpool.conf.template" >"$ckpool_conf" + if [ -n "$existing_addr" ] && [ "$existing_addr" != "CHANGEME_BTC2_PAYOUT_ADDRESS" ]; then + tmp="$(mktemp)" + jq --arg a "$existing_addr" '.btcaddress=$a' "$ckpool_conf" >"$tmp" || fail "cannot preserve payout address" + mv "$tmp" "$ckpool_conf" + fi + chown -R 1000:1000 "${data_dir}/pool" 2>/dev/null || true +fi + +if [ ! -f "${data_dir}/pool/config/ckpool.args" ]; then + printf '%s\n' '-B' >"${data_dir}/pool/config/ckpool.args" + chown 1000:1000 "${data_dir}/pool/config/ckpool.args" 2>/dev/null || true +fi + +settings="${data_dir}/ui/state/pool_settings.json" +if [ -f "$settings" ]; then + addr="$(jq -r '.payoutAddress // empty' "$settings" 2>/dev/null || true)" + if [ -n "$addr" ] && [ -f "$ckpool_conf" ]; then + tmp="$(mktemp)" + jq --arg a "$addr" '.btcaddress=$a' "$ckpool_conf" >"$tmp" || fail "cannot apply saved payout address" + mv "$tmp" "$ckpool_conf" + chown 1000:1000 "$ckpool_conf" 2>/dev/null || true + fi +fi diff --git a/willitmod-dev-bc2/data/templates/bitcoinII.conf.template b/willitmod-dev-bc2/data/templates/bitcoinII.conf.template index 88fb6dc..b7522e3 100644 --- a/willitmod-dev-bc2/data/templates/bitcoinII.conf.template +++ b/willitmod-dev-bc2/data/templates/bitcoinII.conf.template @@ -17,7 +17,9 @@ zmqpubhashblock=tcp://0.0.0.0:${BTC2_ZMQ_HASHBLOCK_PORT} # Network port=${BTC2_P2P_PORT} listen=1 -upnp=1 +# No host P2P port is published by this app. Keep automatic router mappings off; +# the node makes outbound connections and remains reachable only inside Compose. +natpmp=0 # Pruning (optional): keep only a rolling tail of recent blocks to reduce retained history. prune=550 diff --git a/willitmod-dev-bc2/docker-compose.yml b/willitmod-dev-bc2/docker-compose.yml index 1650e1f..2959d2c 100644 --- a/willitmod-dev-bc2/docker-compose.yml +++ b/willitmod-dev-bc2/docker-compose.yml @@ -10,11 +10,27 @@ services: - umbrel_main_network init: - image: alpine:3.22.1 + image: alpine:3.22.1@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1 volumes: - - ${APP_DATA_DIR}/data:/data - - ${APP_DATA_DIR}:/appdata - - ${APP_DATA_DIR}/data/templates:/data/templates:rw + - type: bind + source: /etc/5tratumos/build.json + target: /etc/5tratumos/build.json + read_only: true + bind: + create_host_path: false + - type: bind + source: ${APP_DATA_DIR}/data + target: /data + - type: bind + source: ${APP_DATA_DIR} + target: /appdata + - type: bind + source: ${APP_DATA_DIR}/data/templates + target: /data/templates + - type: bind + source: ${APP_DATA_DIR}/data/init/init.sh + target: /opt/axebc2/init.sh + read_only: true environment: APPS_SUBNET: "${NETWORK_IP}/16" RPC_USER: "btc2" @@ -26,88 +42,10 @@ services: command: - /bin/sh - -ec - - |- - set -eu - - apk add --no-cache envsubst jq >/dev/null - - # Persist JWT secret into ${APP_DATA_DIR}/.env so that SSH restarts - # (without exports.sh) don't boot app_proxy with an empty JWT_SECRET. - if [ -n "${JWT_SECRET:-}" ]; then - envfile="/appdata/.env" - tmp="/appdata/.env.tmp" - if [ -f "$$envfile" ]; then - grep -v '^JWT_SECRET=' "$$envfile" > "$$tmp" || true - else - : > "$$tmp" - fi - printf "JWT_SECRET=%s\n" "${JWT_SECRET}" >> "$$tmp" - chmod 600 "$$tmp" || true - chown 1000:1000 "$$tmp" || true - mv "$$tmp" "$$envfile" - fi - - mkdir -p /data/node /data/pool/config /data/pool/www/pool /data/pool/www/users - - # The host may create settings.yml as root during update/install; ensure it stays writable. - touch /appdata/settings.yml - chown 1000:1000 /appdata/settings.yml || true - - if [ ! -f /data/node/bitcoinII.conf ]; then - envsubst < /data/templates/bitcoinII.conf.template > /data/node/bitcoinII.conf - chown -R 1000:1000 /data/node || true - fi - - needs_ckpool_regen=0 - if [ -f /data/pool/config/ckpool.conf ]; then - if ! grep -qE '"btcd"[[:space:]]*:[[:space:]]*\[' /data/pool/config/ckpool.conf; then - needs_ckpool_regen=1 - fi - if ! grep -q '"zmqblock"' /data/pool/config/ckpool.conf; then - needs_ckpool_regen=1 - fi - else - needs_ckpool_regen=1 - fi - - if [ "$$needs_ckpool_regen" -eq 1 ]; then - existing_addr="$$(grep -oE '\"btcaddress\"[[:space:]]*:[[:space:]]*\"[^\"]*\"' /data/pool/config/ckpool.conf 2>/dev/null | head -n 1 | sed -E 's/.*\"btcaddress\"[[:space:]]*:[[:space:]]*\"([^\"]*)\".*/\1/' || true)" - if [ -f /data/pool/config/ckpool.conf ]; then - mv /data/pool/config/ckpool.conf "/data/pool/config/ckpool.conf.bak.$$(date +%s 2>/dev/null || echo 0)" || true - fi - envsubst < /data/templates/ckpool.conf.template > /data/pool/config/ckpool.conf - if [ -n "$$existing_addr" ] && [ "$$existing_addr" != "CHANGEME_BTC2_PAYOUT_ADDRESS" ]; then - tmp="$$(mktemp)" - if jq --arg a "$$existing_addr" '.btcaddress=$$a' /data/pool/config/ckpool.conf > "$$tmp" 2>/dev/null; then - mv "$$tmp" /data/pool/config/ckpool.conf - else - rm -f "$$tmp" || true - fi - fi - chown -R 1000:1000 /data/pool || true - fi - - if [ ! -f /data/pool/config/ckpool.args ]; then - echo "-B" > /data/pool/config/ckpool.args - chown 1000:1000 /data/pool/config/ckpool.args || true - fi - - # Apply saved UI settings (if present) to ckpool.conf so payouts go to the configured address. - if [ -f /data/ui/state/pool_settings.json ]; then - addr="$(jq -r '.payoutAddress // empty' /data/ui/state/pool_settings.json 2>/dev/null || true)" - if [ -n "$$addr" ] && [ -f /data/pool/config/ckpool.conf ]; then - tmp="$$(mktemp)" - if jq --arg a "$$addr" '.btcaddress=$$a' /data/pool/config/ckpool.conf > "$$tmp" 2>/dev/null; then - mv "$$tmp" /data/pool/config/ckpool.conf - chown 1000:1000 /data/pool/config/ckpool.conf || true - else - rm -f "$$tmp" || true - fi - fi - fi + - exec /bin/sh /opt/axebc2/init.sh btc2d: - image: ghcr.io/willitmod/bitcoinii-core:29.1.0 + image: ghcr.io/willitmod/bitcoinii-core:31.1.0-dev@sha256:CORE31_CANDIDATE_DIGEST_REQUIRED user: "1000:1000" restart: unless-stopped stop_grace_period: 15m30s @@ -118,7 +56,7 @@ services: - ${APP_DATA_DIR}/data/node:/data ckpool: - image: ghcr.io/willitmod/docker-ckpool-solo:590fb2a + image: ghcr.io/willitmod/docker-ckpool-solo:590fb2a@sha256:8a9a7f10c8138d0f55533132ee7710a06715a42a49f75efb39be3350ada4fa6e user: "1000:1000" restart: on-failure depends_on: @@ -150,7 +88,7 @@ services: fi app: - image: ghcr.io/willitmod/axebc2-app-umbrel-dev:0.1.9-dev + image: ghcr.io/willitmod/axebc2-app-umbrel-dev:0.1.10-dev@sha256:APP_CANDIDATE_DIGEST_REQUIRED user: "1000:1000" restart: on-failure stop_grace_period: 30s @@ -172,8 +110,9 @@ services: STATIC_DIR: "/app/static" APP_CHANNEL: "ALPHA" APP_VERSION_SUFFIX: "-dev" - BTC2D_IMAGE: "ghcr.io/willitmod/bitcoinii-core:29.1.0" - CKPOOL_IMAGE: "ghcr.io/willitmod/docker-ckpool-solo:590fb2a" + BTC2D_IMAGE: "ghcr.io/willitmod/bitcoinii-core:31.1.0-dev@sha256:CORE31_CANDIDATE_DIGEST_REQUIRED" + CKPOOL_IMAGE: "ghcr.io/willitmod/docker-ckpool-solo:590fb2a@sha256:8a9a7f10c8138d0f55533132ee7710a06715a42a49f75efb39be3350ada4fa6e" + SUPPORT_CHECKIN_ENABLED: "false" BTC2_RPC_HOST: "btc2d" BTC2_RPC_PORT: "8337" BTC2_RPC_USER: "btc2" diff --git a/willitmod-dev-bc2/umbrel-app.yml b/willitmod-dev-bc2/umbrel-app.yml index 072f5ea..e2b05cb 100644 --- a/willitmod-dev-bc2/umbrel-app.yml +++ b/willitmod-dev-bc2/umbrel-app.yml @@ -2,7 +2,7 @@ manifestVersion: 1 id: willitmod-dev-bc2 category: bitcoin name: AxeBC2 -version: "0.1.9-dev" +version: "0.1.10-dev" tagline: BC2 node + solo pool description: >- ALPHA RELEASE (DEV CHANNEL) @@ -25,6 +25,8 @@ description: >- Notes: - Set your payout address in the app Settings tab. + - Requires 5tratumOS 0.7.11 or newer. + - The node makes outbound peer connections; this app does not publish a public P2P port or request NAT-PMP mappings. - Initial sync can take a long time and a lot of bandwidth/storage (the node downloads and validates the chain). - Solo mining is extremely unlikely to find blocks without significant hashrate. @@ -45,10 +47,12 @@ path: "" defaultUsername: "" defaultPassword: "" releaseNotes: >- - Workers using either coin-prefixed or prefixless payout usernames now appear - as one combined worker entry in the app and Fleet Dashboard APIs. Hashrate, - shares and best-share data are merged without double-counting duplicate - worker names. + Upgrades BitcoinII Core to 31.1 for the ShockWave consensus change. Requires + 5tratumOS 0.7.11 or newer. Back up your app data before upgrading. Existing + nodes must perform a full blockchain redownload and reindex; mining and sync + data will be unavailable until validation completes. Stratum remains on TCP + port 2345. Support telemetry is disabled by default. The node uses outbound + peer connections only: no public P2P port or NAT-PMP mapping is enabled. widgets: - id: "sync" type: "text-with-progress" From 41103b9fdec3804fd341f47eb67d0a564233bd3c Mon Sep 17 00:00:00 2001 From: Johnny Murray Date: Wed, 2 Sep 2026 19:56:02 +0100 Subject: [PATCH 02/18] Harden AxeBC2 DEV store preflight --- .../workflows/validate-axebc2-core31-dev.yml | 6 +- scripts/validate-axebc2-core31-dev.py | 90 ++++++++++++++ tests/fixtures/5tratumos_contract_4f979cb.py | 59 +++++++++ tests/test_axebc2_core31_init.py | 24 ++++ tests/test_axebc2_platform_integration.py | 113 ++++++++++++++++++ willitmod-dev-bc2/CORE31-DEV-RELEASE.md | 27 +++++ willitmod-dev-bc2/data/init/init.sh | 5 +- willitmod-dev-bc2/data/node/.gitkeep | 1 + willitmod-dev-bc2/data/pool/config/.gitkeep | 1 + willitmod-dev-bc2/data/pool/www/.gitkeep | 1 + willitmod-dev-bc2/docker-compose.yml | 33 ++++- 11 files changed, 354 insertions(+), 6 deletions(-) create mode 100644 tests/fixtures/5tratumos_contract_4f979cb.py create mode 100644 tests/test_axebc2_platform_integration.py create mode 100644 willitmod-dev-bc2/CORE31-DEV-RELEASE.md create mode 100644 willitmod-dev-bc2/data/node/.gitkeep create mode 100644 willitmod-dev-bc2/data/pool/config/.gitkeep create mode 100644 willitmod-dev-bc2/data/pool/www/.gitkeep diff --git a/.github/workflows/validate-axebc2-core31-dev.yml b/.github/workflows/validate-axebc2-core31-dev.yml index c928f18..4f07bbb 100644 --- a/.github/workflows/validate-axebc2-core31-dev.yml +++ b/.github/workflows/validate-axebc2-core31-dev.yml @@ -5,6 +5,8 @@ on: paths: - "willitmod-dev-bc2/**" - "tests/test_axebc2_core31_init.py" + - "tests/test_axebc2_platform_integration.py" + - "tests/fixtures/5tratumos_contract_4f979cb.py" - "scripts/validate-axebc2-core31-dev.py" - ".github/workflows/validate-axebc2-core31-dev.yml" push: @@ -12,6 +14,8 @@ on: paths: - "willitmod-dev-bc2/**" - "tests/test_axebc2_core31_init.py" + - "tests/test_axebc2_platform_integration.py" + - "tests/fixtures/5tratumos_contract_4f979cb.py" - "scripts/validate-axebc2-core31-dev.py" - ".github/workflows/validate-axebc2-core31-dev.yml" @@ -26,7 +30,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Install test dependencies - run: sudo apt-get update && sudo apt-get install --yes gettext-base jq + run: sudo apt-get update && sudo apt-get install --yes gettext-base jq python3-yaml - name: Validate metadata and migration initialization run: python3 scripts/validate-axebc2-core31-dev.py diff --git a/scripts/validate-axebc2-core31-dev.py b/scripts/validate-axebc2-core31-dev.py index 026c784..05b720d 100644 --- a/scripts/validate-axebc2-core31-dev.py +++ b/scripts/validate-axebc2-core31-dev.py @@ -1,8 +1,12 @@ #!/usr/bin/env python3 from pathlib import Path +import json +import os import re +import shutil import subprocess import sys +import tempfile import unittest @@ -45,6 +49,92 @@ def require(condition, message): for placeholder in ("CORE31_CANDIDATE_DIGEST_REQUIRED", "APP_CANDIDATE_DIGEST_REQUIRED"): require(placeholder in compose, f"pending digest sentinel is missing: {placeholder}") +require( + compose.count("create_host_path: false") == 9, + "every AxeBC2 host bind must disable implicit source-path creation", +) + + +def yaml_python(): + candidates = [os.environ.get("YAML_PYTHON"), "/usr/bin/python3", sys.executable] + for candidate in candidates: + if candidate and Path(candidate).is_file(): + check = subprocess.run( + [candidate, "-c", "import yaml"], capture_output=True, check=False + ) + if check.returncode == 0: + return candidate + raise SystemExit("PyYAML-capable Python is required for merged Compose validation") + + +def validate_platform_merged_compose(): + docker = shutil.which("docker") + require(docker is not None, "Docker Compose is required for merged Compose validation") + with tempfile.TemporaryDirectory(prefix="axebc2-compose-") as raw_temp: + temp = Path(raw_temp) + app_data = temp / "state/apps/axebc2" + for relative in ( + "data/templates", + "data/init", + "data/node", + "data/pool/config", + "data/pool/www", + ): + (app_data / relative).mkdir(parents=True, exist_ok=True) + (app_data / "data/init/init.sh").write_text("#!/bin/sh\n", encoding="utf-8") + source = temp / "docker-compose.yml" + source.write_text( + compose.replace("CORE31_CANDIDATE_DIGEST_REQUIRED", "a" * 64).replace( + "APP_CANDIDATE_DIGEST_REQUIRED", "b" * 64 + ), + encoding="utf-8", + ) + merged = temp / "platform-merged-compose.json" + transform = """ +import json, sys, yaml +with open(sys.argv[1], encoding='utf-8') as handle: + config = yaml.safe_load(handle) +config['services'].pop('app_proxy', None) +with open(sys.argv[2], 'w', encoding='utf-8') as handle: + json.dump(config, handle) +""" + subprocess.run([yaml_python(), "-c", transform, source, merged], check=True) + env = os.environ.copy() + env.update( + { + "APP_DATA_DIR": str(app_data), + "APP_PASSWORD": "validation-only", + "JWT_SECRET": "validation-only", + "NETWORK_IP": "10.21.0.0", + } + ) + result = subprocess.run( + [docker, "compose", "-f", str(merged), "config", "--format", "json"], + env=env, + text=True, + capture_output=True, + check=False, + ) + require(result.returncode == 0, f"merged Compose is invalid: {result.stderr}") + rendered = json.loads(result.stdout) + services = rendered["services"] + require("app_proxy" not in services, "platform merge must remove legacy app_proxy") + require( + services["btc2d"]["depends_on"]["init"]["condition"] + == "service_completed_successfully", + "Core must wait for successful init completion", + ) + for service in services.values(): + for volume in service.get("volumes", []): + if volume.get("type") == "bind": + require( + volume.get("bind", {}).get("create_host_path") is False, + "rendered Compose contains an implicit host-path bind", + ) + + +validate_platform_merged_compose() + subprocess.run(["sh", "-n", str(APP / "data/init/init.sh")], check=True) suite = unittest.defaultTestLoader.discover(str(ROOT / "tests"), pattern="test_axebc2_*.py") result = unittest.TextTestRunner(verbosity=2).run(suite) diff --git a/tests/fixtures/5tratumos_contract_4f979cb.py b/tests/fixtures/5tratumos_contract_4f979cb.py new file mode 100644 index 0000000..37b88f9 --- /dev/null +++ b/tests/fixtures/5tratumos_contract_4f979cb.py @@ -0,0 +1,59 @@ +"""Pinned excerpt of the 5tratumOS app-ID and rollback-policy contract. + +Source: WillItMod/5tratum_Build commit +4f979cb9541622c1fdccdf43b8a885bbf845ba38. The integration test prefers a +local platform checkout and uses this fixture in isolated store CI. +""" + +import json +from pathlib import Path +import re + + +_STORE_ID_PREFIXES = ("willitmod-dev-", "willitmod-") +_CANONICAL_STORE_APP_IDS = {"5tratsmack"} +_VERSION_RE = re.compile( + r"^v?(?P[0-9]+(?:\.[0-9]+){1,3})(?:[-+][0-9A-Za-z][0-9A-Za-z.-]*)?$" +) + + +class RollbackPolicyError(ValueError): + pass + + +def map_store_id_to_app_id(store_id: str, channel: str) -> str: + raw = (store_id or "").strip().lower() + ch = (channel or "").strip().lower() + if ch == "global" or ch.startswith("custom"): + raw = raw.replace(" ", "-") + raw = re.sub(r"[^a-z0-9_-]+", "", raw) + return raw or "app" + for prefix in _STORE_ID_PREFIXES: + if raw.startswith(prefix): + raw = raw[len(prefix) :] + break + raw = raw.replace("_", "").replace("-", "") + if raw in _CANONICAL_STORE_APP_IDS: + return raw + if not raw.startswith("axe"): + raw = f"axe{raw}" + return raw + + +def _base_version(value): + match = _VERSION_RE.fullmatch(str(value or "").strip()) + if not match: + raise RollbackPolicyError("invalid version") + parts = tuple(int(part) for part in match.group("base").split(".")) + return parts + (0,) * (4 - len(parts)) + + +def check_rollback_policy(policy_path: Path, app_id: str, target_version: str) -> dict: + policy = json.loads(Path(policy_path).read_text(encoding="utf-8")) + if policy.get("schema") != 1 or policy.get("app_id") != app_id: + raise RollbackPolicyError("rollback policy app contract mismatch") + _base_version(policy.get("minimum_5tratumos_version")) + minimum = str(policy.get("minimum_base_version") or "") + if _base_version(target_version) < _base_version(minimum): + raise RollbackPolicyError("rollback denied") + return {"enforced": True, "app_id": app_id, "minimum_base_version": minimum} diff --git a/tests/test_axebc2_core31_init.py b/tests/test_axebc2_core31_init.py index 3134ff8..5021f97 100644 --- a/tests/test_axebc2_core31_init.py +++ b/tests/test_axebc2_core31_init.py @@ -151,6 +151,30 @@ def test_missing_or_malformed_build_metadata_fails_closed(self): self.assertEqual(result.returncode, 78) self.assertEqual(list(self.data.iterdir()), []) + def test_dependency_install_failure_precedes_persistent_mutation(self): + tool_dir = self.tmp / "broken-tools" + tool_dir.mkdir() + apk = tool_dir / "apk" + apk.write_text("#!/bin/sh\nexit 42\n", encoding="utf-8") + apk.chmod(0o755) + self.build.write_text(json.dumps({"tag": "0.7.11"}), encoding="utf-8") + sentinel = self.data / "unchanged" + sentinel.write_text("original", encoding="utf-8") + env = os.environ.copy() + env.update( + { + "PATH": str(tool_dir), + "AXEBC2_DATA_DIR": str(self.data), + "AXEBC2_APPDATA_DIR": str(self.appdata), + "AXEBC2_BUILD_FILE": str(self.build), + } + ) + result = subprocess.run(["/bin/sh", str(INIT)], env=env, capture_output=True) + self.assertEqual(result.returncode, 42) + self.assertEqual(sentinel.read_text(encoding="utf-8"), "original") + self.assertEqual(sorted(p.name for p in self.data.iterdir()), ["unchanged"]) + self.assertEqual(list(self.appdata.iterdir()), []) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_axebc2_platform_integration.py b/tests/test_axebc2_platform_integration.py new file mode 100644 index 0000000..77a5337 --- /dev/null +++ b/tests/test_axebc2_platform_integration.py @@ -0,0 +1,113 @@ +import ast +import importlib.util +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +INIT = ROOT / "willitmod-dev-bc2/data/init/init.sh" +TEMPLATES = ROOT / "willitmod-dev-bc2/data/templates" + + +def platform_root() -> Path: + configured = os.environ.get("FIVETRATUMOS_ROOT") + if configured: + return Path(configured) + return ROOT.parent / "5tratumos-v0711-release" + + +def load_fixture_module(): + source = ROOT / "tests/fixtures/5tratumos_contract_4f979cb.py" + spec = importlib.util.spec_from_file_location("pinned_5tratumos_contract", source) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def load_platform_store_mapper(platform: Path): + source_path = platform / "daemon/5tratumosd.py" + tree = ast.parse(source_path.read_text(encoding="utf-8"), filename=str(source_path)) + required_assignments = {"_STORE_ID_PREFIXES", "_CANONICAL_STORE_APP_IDS"} + selected = [] + for node in tree.body: + if isinstance(node, ast.Assign) and any( + isinstance(target, ast.Name) and target.id in required_assignments + for target in node.targets + ): + selected.append(node) + if isinstance(node, ast.FunctionDef) and node.name == "map_store_id_to_app_id": + selected.append(node) + namespace = {"re": re} + exec(compile(ast.Module(body=selected, type_ignores=[]), str(source_path), "exec"), namespace) + return namespace["map_store_id_to_app_id"] + + +def load_policy_module(platform: Path): + source = platform / "daemon/app_rollback_policy.py" + spec = importlib.util.spec_from_file_location("platform_app_rollback_policy", source) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +class AxeBC2PlatformIntegrationTests(unittest.TestCase): + def test_real_dev_store_id_maps_to_axebc2_and_policy_is_accepted(self): + platform = platform_root() + if platform.joinpath("daemon/5tratumosd.py").is_file(): + mapper = load_platform_store_mapper(platform) + policy = load_policy_module(platform) + else: + policy = load_fixture_module() + mapper = policy.map_store_id_to_app_id + canonical_id = mapper("willitmod-dev-bc2", "dev") + self.assertEqual(canonical_id, "axebc2") + self.assertEqual( + Path("/var/lib/5tratumos/apps") / canonical_id, + Path("/var/lib/5tratumos/apps/axebc2"), + ) + + temp = Path(tempfile.mkdtemp(prefix="axebc2-platform-")) + try: + app_root = temp / "var/lib/5tratumos/apps" / canonical_id + data = app_root / "data" + data.mkdir(parents=True) + build = temp / "etc/5tratumos/build.json" + build.parent.mkdir(parents=True) + build.write_text(json.dumps({"tag": "0.7.11"}), encoding="utf-8") + env = os.environ.copy() + env.update( + { + "AXEBC2_DATA_DIR": str(data), + "AXEBC2_APPDATA_DIR": str(app_root), + "AXEBC2_BUILD_FILE": str(build), + "AXEBC2_TEMPLATES_DIR": str(TEMPLATES), + "AXEBC2_TEST_SKIP_CHOWN": "true", + "APPS_SUBNET": "10.0.0.0/16", + "RPC_USER": "btc2", + "RPC_PASSWORD": "test-only", + "BTC2_RPC_PORT": "8337", + "BTC2_P2P_PORT": "8338", + "BTC2_ZMQ_HASHBLOCK_PORT": "28336", + "PAYOUT_ADDRESS": "CHANGEME_BTC2_PAYOUT_ADDRESS", + } + ) + subprocess.run(["sh", str(INIT)], env=env, check=True, capture_output=True) + policy_path = data / ".5tratumos-rollback-policy.json" + accepted = policy.check_rollback_policy(policy_path, canonical_id, "0.1.10-dev") + self.assertTrue(accepted["enforced"]) + with self.assertRaises(policy.RollbackPolicyError): + policy.check_rollback_policy(policy_path, canonical_id, "0.1.9-dev") + finally: + shutil.rmtree(temp) + + +if __name__ == "__main__": + unittest.main() diff --git a/willitmod-dev-bc2/CORE31-DEV-RELEASE.md b/willitmod-dev-bc2/CORE31-DEV-RELEASE.md new file mode 100644 index 0000000..1b4f62d --- /dev/null +++ b/willitmod-dev-bc2/CORE31-DEV-RELEASE.md @@ -0,0 +1,27 @@ +# AxeBC2 Core 31 DEV release gates + +The DEV recipe maps store ID `willitmod-dev-bc2` to canonical 5tratumOS app ID +`axebc2`. Its preserved data path is `/var/lib/5tratumos/apps/axebc2`, matching +the `app_id` in `.5tratumos-rollback-policy.json`. + +Every host bind uses `create_host_path: false`. The recipe contains the empty +runtime directories that 5tratumOS stages before Compose validation, so Docker +must not silently create a misspelled or missing source path. + +The digest-pinned generic Alpine init container installs `jq` and +`gettext-envsubst` from Alpine 3.22 repositories at startup. This remains a +network-availability dependency, but an install failure occurs before any +persistent app-data or node-data mutation and prevents Core from starting. A +future dedicated, independently built and digest-pinned init image could remove +that availability dependency; it is not introduced in this consensus release. + +The committed Compose file deliberately retains these non-runnable sentinels: + +- `CORE31_CANDIDATE_DIGEST_REQUIRED` +- `APP_CANDIDATE_DIGEST_REQUIRED` + +They must be replaced with the exact verified multi-architecture candidate +digests. After substitution, the merged platform Compose must pass validation, +all images must pull anonymously by digest, init must complete successfully on +5tratumOS 0.7.11+, and the resulting installation must be tested on DEV before +any production promotion. diff --git a/willitmod-dev-bc2/data/init/init.sh b/willitmod-dev-bc2/data/init/init.sh index 496ae8b..ec42933 100644 --- a/willitmod-dev-bc2/data/init/init.sh +++ b/willitmod-dev-bc2/data/init/init.sh @@ -19,7 +19,10 @@ fail() { } if ! command -v jq >/dev/null 2>&1 || ! command -v envsubst >/dev/null 2>&1; then - apk add --no-cache envsubst jq >/dev/null + # The digest-pinned Alpine image is intentionally kept generic. These tools + # come from its configured 3.22 repositories; a repository/network failure + # exits here, before the OS check and before any persistent-data write. + apk add --no-cache gettext-envsubst jq >/dev/null fi version_normalize() { diff --git a/willitmod-dev-bc2/data/node/.gitkeep b/willitmod-dev-bc2/data/node/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/willitmod-dev-bc2/data/node/.gitkeep @@ -0,0 +1 @@ + diff --git a/willitmod-dev-bc2/data/pool/config/.gitkeep b/willitmod-dev-bc2/data/pool/config/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/willitmod-dev-bc2/data/pool/config/.gitkeep @@ -0,0 +1 @@ + diff --git a/willitmod-dev-bc2/data/pool/www/.gitkeep b/willitmod-dev-bc2/data/pool/www/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/willitmod-dev-bc2/data/pool/www/.gitkeep @@ -0,0 +1 @@ + diff --git a/willitmod-dev-bc2/docker-compose.yml b/willitmod-dev-bc2/docker-compose.yml index 2959d2c..5dd20fa 100644 --- a/willitmod-dev-bc2/docker-compose.yml +++ b/willitmod-dev-bc2/docker-compose.yml @@ -21,16 +21,24 @@ services: - type: bind source: ${APP_DATA_DIR}/data target: /data + bind: + create_host_path: false - type: bind source: ${APP_DATA_DIR} target: /appdata + bind: + create_host_path: false - type: bind source: ${APP_DATA_DIR}/data/templates target: /data/templates + bind: + create_host_path: false - type: bind source: ${APP_DATA_DIR}/data/init/init.sh target: /opt/axebc2/init.sh read_only: true + bind: + create_host_path: false environment: APPS_SUBNET: "${NETWORK_IP}/16" RPC_USER: "btc2" @@ -53,7 +61,11 @@ services: init: condition: service_completed_successfully volumes: - - ${APP_DATA_DIR}/data/node:/data + - type: bind + source: ${APP_DATA_DIR}/data/node + target: /data + bind: + create_host_path: false ckpool: image: ghcr.io/willitmod/docker-ckpool-solo:590fb2a@sha256:8a9a7f10c8138d0f55533132ee7710a06715a42a49f75efb39be3350ada4fa6e @@ -67,8 +79,17 @@ services: ports: - "2345:3333/tcp" volumes: - - ${APP_DATA_DIR}/data/pool/config:/config:ro - - ${APP_DATA_DIR}/data/pool/www:/www + - type: bind + source: ${APP_DATA_DIR}/data/pool/config + target: /config + read_only: true + bind: + create_host_path: false + - type: bind + source: ${APP_DATA_DIR}/data/pool/www + target: /www + bind: + create_host_path: false entrypoint: - /bin/sh - -ec @@ -104,7 +125,11 @@ services: aliases: - axebc2-app volumes: - - ${APP_DATA_DIR}/data:/data + - type: bind + source: ${APP_DATA_DIR}/data + target: /data + bind: + create_host_path: false environment: NETWORK_IP: "${NETWORK_IP}" STATIC_DIR: "/app/static" From 0e312355043ec0b80f5e0d3e9aa5363b0240ce1a Mon Sep 17 00:00:00 2001 From: Johnny Murray Date: Wed, 2 Sep 2026 19:59:51 +0100 Subject: [PATCH 03/18] Pass init JWT through DEV store preflight --- scripts/validate-axebc2-core31-dev.py | 32 +++++++++- tests/fixtures/5tratumos_contract_4f979cb.py | 64 ++++++++++++++++++++ tests/test_axebc2_core31_init.py | 27 ++++++++- tests/test_axebc2_platform_integration.py | 15 +++++ willitmod-dev-bc2/CORE31-DEV-RELEASE.md | 10 +++ willitmod-dev-bc2/docker-compose.yml | 1 + 6 files changed, 146 insertions(+), 3 deletions(-) diff --git a/scripts/validate-axebc2-core31-dev.py b/scripts/validate-axebc2-core31-dev.py index 05b720d..b1307f8 100644 --- a/scripts/validate-axebc2-core31-dev.py +++ b/scripts/validate-axebc2-core31-dev.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 from pathlib import Path +import importlib.util import json import os import re @@ -29,6 +30,7 @@ def require(condition, message): require("SUPPORT_CHECKIN_ENABLED: \"false\"" in compose, "telemetry must default off") require("create_host_path: false" in compose, "build metadata bind must fail closed") require("/etc/5tratumos/build.json" in compose, "build metadata must be mounted") +require('JWT_SECRET: "${JWT_SECRET}"' in compose, "init must receive the platform JWT secret") require( ".5tratumos-rollback-policy.json" in (APP / "data/init/init.sh").read_text(encoding="utf-8"), "init must use the policy filename consumed by AxeBC2 and 5tratumOS", @@ -89,16 +91,25 @@ def validate_platform_merged_compose(): ), encoding="utf-8", ) + parsed = temp / "parsed-compose.json" merged = temp / "platform-merged-compose.json" transform = """ import json, sys, yaml with open(sys.argv[1], encoding='utf-8') as handle: config = yaml.safe_load(handle) -config['services'].pop('app_proxy', None) with open(sys.argv[2], 'w', encoding='utf-8') as handle: json.dump(config, handle) """ - subprocess.run([yaml_python(), "-c", transform, source, merged], check=True) + subprocess.run([yaml_python(), "-c", transform, source, parsed], check=True) + contract_path = ROOT / "tests/fixtures/5tratumos_contract_4f979cb.py" + spec = importlib.util.spec_from_file_location("pinned_5tratumos_contract", contract_path) + contract = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(contract) + rendered_contract = contract.materialize_compose( + json.loads(parsed.read_text(encoding="utf-8")), 21219 + ) + merged.write_text(json.dumps(rendered_contract), encoding="utf-8") env = os.environ.copy() env.update( { @@ -119,6 +130,23 @@ def validate_platform_merged_compose(): rendered = json.loads(result.stdout) services = rendered["services"] require("app_proxy" not in services, "platform merge must remove legacy app_proxy") + require( + services["init"]["environment"]["JWT_SECRET"] == "validation-only", + "platform-merged init service must receive JWT_SECRET", + ) + require( + services["app"]["ports"] == [{"mode": "ingress", "target": 3000, "published": "21219", "protocol": "tcp"}], + "platform merge must materialize the app-proxy host port on the app service", + ) + require( + "umbrel_main_network" not in rendered.get("networks", {}), + "platform merge must remove the legacy shared network", + ) + require( + services["app"]["restart"] == "unless-stopped" + and services["ckpool"]["restart"] == "unless-stopped", + "platform merge must normalize service restart policies", + ) require( services["btc2d"]["depends_on"]["init"]["condition"] == "service_completed_successfully", diff --git a/tests/fixtures/5tratumos_contract_4f979cb.py b/tests/fixtures/5tratumos_contract_4f979cb.py index 37b88f9..831de59 100644 --- a/tests/fixtures/5tratumos_contract_4f979cb.py +++ b/tests/fixtures/5tratumos_contract_4f979cb.py @@ -57,3 +57,67 @@ def check_rollback_policy(policy_path: Path, app_id: str, target_version: str) - if _base_version(target_version) < _base_version(minimum): raise RollbackPolicyError("rollback denied") return {"enforced": True, "app_id": app_id, "minimum_base_version": minimum} + + +def materialize_compose(compose: dict, host_port: int) -> dict: + """Relevant store materialization contract from bin/5tratumos.""" + services = compose.get("services") or {} + + def env_to_dict(env): + if isinstance(env, dict): + return {str(key).strip(): "" if value is None else str(value) for key, value in env.items() if str(key).strip()} + if isinstance(env, list): + return dict(str(item).split("=", 1) for item in env if "=" in str(item)) + return {} + + proxy = services.get("app_proxy") if isinstance(services.get("app_proxy"), dict) else None + proxy_env = env_to_dict(proxy.get("environment") if proxy else None) + app_host = str(proxy_env.get("APP_HOST") or "").strip() + app_port = int(str(proxy_env.get("APP_PORT") or host_port)) + services.pop("app_proxy", None) + + for name, service in list(services.items()): + if not isinstance(service, dict): + continue + dependencies = service.get("depends_on") + if isinstance(dependencies, list): + service["depends_on"] = [item for item in dependencies if item != "app_proxy"] + elif isinstance(dependencies, dict): + dependencies.pop("app_proxy", None) + restart = str(service.get("restart") or "").strip().lower() + if name != "init" and (not restart or restart.startswith("on-failure")): + service["restart"] = "unless-stopped" + + ui_service = app_host if app_host in services else "app" if "app" in services else None + if ui_service and host_port: + ports = services[ui_service].get("ports") or [] + if not any(str(item).split("/", 1)[0].split(":")[0] == str(host_port) for item in ports): + ports.append(f"{host_port}:{app_port}") + services[ui_service]["ports"] = ports + + compose["services"] = services + compose.pop("version", None) + networks = compose.get("networks") + dropped = set() + if isinstance(networks, dict): + for name, config in list(networks.items()): + configured_name = str(config.get("name") or "") if isinstance(config, dict) else "" + if str(name).endswith("_main_network") or configured_name.endswith("_main_network"): + networks.pop(name) + dropped.add(str(name)) + if not networks: + compose.pop("networks", None) + for service in services.values(): + service_networks = service.get("networks") if isinstance(service, dict) else None + if isinstance(service_networks, list): + remaining = [name for name in service_networks if str(name) not in dropped] + if remaining: + service["networks"] = remaining + else: + service.pop("networks", None) + elif isinstance(service_networks, dict): + for name in dropped: + service_networks.pop(name, None) + if not service_networks: + service.pop("networks", None) + return compose diff --git a/tests/test_axebc2_core31_init.py b/tests/test_axebc2_core31_init.py index 5021f97..d8fe988 100644 --- a/tests/test_axebc2_core31_init.py +++ b/tests/test_axebc2_core31_init.py @@ -2,6 +2,7 @@ import os from pathlib import Path import shutil +import stat import subprocess import tempfile import unittest @@ -24,9 +25,10 @@ def setUp(self): def tearDown(self): shutil.rmtree(self.tmp) - def run_init(self, tag="0.7.11", expect=0): + def run_init(self, tag="0.7.11", expect=0, jwt_secret=None): self.build.write_text(json.dumps({"tag": tag}), encoding="utf-8") env = os.environ.copy() + env.pop("JWT_SECRET", None) env.update( { "AXEBC2_DATA_DIR": str(self.data), @@ -43,12 +45,35 @@ def run_init(self, tag="0.7.11", expect=0): "PAYOUT_ADDRESS": "CHANGEME_BTC2_PAYOUT_ADDRESS", } ) + if jwt_secret is not None: + env["JWT_SECRET"] = jwt_secret result = subprocess.run( ["sh", str(INIT)], env=env, text=True, capture_output=True, check=False ) self.assertEqual(result.returncode, expect, result.stderr) return result + def test_fresh_env_persists_jwt_secret_with_private_mode(self): + self.run_init(jwt_secret="fresh-secret") + envfile = self.appdata / ".env" + self.assertEqual(envfile.read_text(encoding="utf-8"), "JWT_SECRET=fresh-secret\n") + self.assertEqual(stat.S_IMODE(envfile.stat().st_mode), 0o600) + + def test_existing_env_replaces_all_jwts_and_preserves_unrelated_entries(self): + envfile = self.appdata / ".env" + envfile.write_text( + "KEEP_FIRST=alpha\nJWT_SECRET=old-one\nKEEP_SECOND=beta=value\nJWT_SECRET=old-two\n", + encoding="utf-8", + ) + envfile.chmod(0o644) + self.run_init(jwt_secret="replacement-secret") + lines = envfile.read_text(encoding="utf-8").splitlines() + self.assertEqual(lines.count("JWT_SECRET=replacement-secret"), 1) + self.assertFalse(any(line in {"JWT_SECRET=old-one", "JWT_SECRET=old-two"} for line in lines)) + self.assertIn("KEEP_FIRST=alpha", lines) + self.assertIn("KEEP_SECOND=beta=value", lines) + self.assertEqual(stat.S_IMODE(envfile.stat().st_mode), 0o600) + def test_policy_and_reindex_requirement_exist_without_rpc(self): (self.data / "node/blocks").mkdir(parents=True) self.run_init() diff --git a/tests/test_axebc2_platform_integration.py b/tests/test_axebc2_platform_integration.py index 77a5337..4a1d067 100644 --- a/tests/test_axebc2_platform_integration.py +++ b/tests/test_axebc2_platform_integration.py @@ -59,6 +59,21 @@ def load_policy_module(platform: Path): class AxeBC2PlatformIntegrationTests(unittest.TestCase): + def test_pinned_materialization_contract_matches_platform_source(self): + platform = platform_root() + cli = platform / "bin/5tratumos" + if not cli.is_file(): + self.skipTest("live platform checkout is not available in isolated store CI") + source = cli.read_text(encoding="utf-8") + for required in ( + 'services.pop("app_proxy", None)', + "ensure_port_mapping(svc, host_port, app_port or host_port)", + 'compose.pop("version", None)', + 'if n.endswith("_main_network"):', + 'svc["restart"] = "unless-stopped"', + ): + self.assertIn(required, source) + def test_real_dev_store_id_maps_to_axebc2_and_policy_is_accepted(self): platform = platform_root() if platform.joinpath("daemon/5tratumosd.py").is_file(): diff --git a/willitmod-dev-bc2/CORE31-DEV-RELEASE.md b/willitmod-dev-bc2/CORE31-DEV-RELEASE.md index 1b4f62d..36512a6 100644 --- a/willitmod-dev-bc2/CORE31-DEV-RELEASE.md +++ b/willitmod-dev-bc2/CORE31-DEV-RELEASE.md @@ -25,3 +25,13 @@ digests. After substitution, the merged platform Compose must pass validation, all images must pull anonymously by digest, init must complete successfully on 5tratumOS 0.7.11+, and the resulting installation must be tested on DEV before any production promotion. + +The store validator exercises a pinned copy of the relevant 5tratumOS +materialization contract from platform commit `4f979cb9541622c1fdccdf43b8a885bbf845ba38`: +it consumes `app_proxy`, publishes the manifest port on the resolved app +service, removes the legacy shared network, and normalizes restart policies. +The platform currently exposes this logic only inside its mutating install and +update commands, so invoking the live implementation from isolated store CI +would require performing a stateful platform transaction. Final DEV acceptance +therefore still runs the real platform materializer and validates its generated +Compose file before containers are started. diff --git a/willitmod-dev-bc2/docker-compose.yml b/willitmod-dev-bc2/docker-compose.yml index 5dd20fa..cf68f4e 100644 --- a/willitmod-dev-bc2/docker-compose.yml +++ b/willitmod-dev-bc2/docker-compose.yml @@ -40,6 +40,7 @@ services: bind: create_host_path: false environment: + JWT_SECRET: "${JWT_SECRET}" APPS_SUBNET: "${NETWORK_IP}/16" RPC_USER: "btc2" RPC_PASSWORD: "${APP_PASSWORD}" From d1aff904ea1b2c759bb3009efb182ac6ee5c11c0 Mon Sep 17 00:00:00 2001 From: Johnny Murray Date: Wed, 2 Sep 2026 20:03:29 +0100 Subject: [PATCH 04/18] Clarify AxeBC2 Core 31 reindex impact --- willitmod-dev-bc2/umbrel-app.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/willitmod-dev-bc2/umbrel-app.yml b/willitmod-dev-bc2/umbrel-app.yml index e2b05cb..8ca2e32 100644 --- a/willitmod-dev-bc2/umbrel-app.yml +++ b/willitmod-dev-bc2/umbrel-app.yml @@ -49,8 +49,8 @@ defaultPassword: "" releaseNotes: >- Upgrades BitcoinII Core to 31.1 for the ShockWave consensus change. Requires 5tratumOS 0.7.11 or newer. Back up your app data before upgrading. Existing - nodes must perform a full blockchain redownload and reindex; mining and sync - data will be unavailable until validation completes. Stratum remains on TCP + nodes must perform a full reindex of their stored blockchain data; mining and + sync data will be unavailable until validation completes. Stratum remains on TCP port 2345. Support telemetry is disabled by default. The node uses outbound peer connections only: no public P2P port or NAT-PMP mapping is enabled. widgets: From 6cf9a8061e17560d02adcf5d5ee3bdb1ad99ac9e Mon Sep 17 00:00:00 2001 From: Johnny Murray Date: Wed, 2 Sep 2026 20:21:57 +0100 Subject: [PATCH 05/18] Add AxeBC2 DEV candidate finalizer --- .../workflows/validate-axebc2-core31-dev.yml | 8 ++- scripts/finalize-axebc2-0.1.10-dev.sh | 70 +++++++++++++++++++ scripts/validate-axebc2-core31-dev.py | 6 ++ tests/test_axebc2_dev_finalizer.py | 67 ++++++++++++++++++ willitmod-dev-bc2/CORE31-DEV-RELEASE.md | 8 +++ willitmod-dev-bc2/docker-compose.yml | 2 +- 6 files changed, 159 insertions(+), 2 deletions(-) create mode 100755 scripts/finalize-axebc2-0.1.10-dev.sh create mode 100644 tests/test_axebc2_dev_finalizer.py diff --git a/.github/workflows/validate-axebc2-core31-dev.yml b/.github/workflows/validate-axebc2-core31-dev.yml index 4f07bbb..b3564e3 100644 --- a/.github/workflows/validate-axebc2-core31-dev.yml +++ b/.github/workflows/validate-axebc2-core31-dev.yml @@ -6,8 +6,10 @@ on: - "willitmod-dev-bc2/**" - "tests/test_axebc2_core31_init.py" - "tests/test_axebc2_platform_integration.py" + - "tests/test_axebc2_dev_finalizer.py" - "tests/fixtures/5tratumos_contract_4f979cb.py" - "scripts/validate-axebc2-core31-dev.py" + - "scripts/finalize-axebc2-0.1.10-dev.sh" - ".github/workflows/validate-axebc2-core31-dev.yml" push: branches: [main] @@ -15,8 +17,10 @@ on: - "willitmod-dev-bc2/**" - "tests/test_axebc2_core31_init.py" - "tests/test_axebc2_platform_integration.py" + - "tests/test_axebc2_dev_finalizer.py" - "tests/fixtures/5tratumos_contract_4f979cb.py" - "scripts/validate-axebc2-core31-dev.py" + - "scripts/finalize-axebc2-0.1.10-dev.sh" - ".github/workflows/validate-axebc2-core31-dev.yml" permissions: @@ -33,4 +37,6 @@ jobs: run: sudo apt-get update && sudo apt-get install --yes gettext-base jq python3-yaml - name: Validate metadata and migration initialization - run: python3 scripts/validate-axebc2-core31-dev.py + run: | + bash -n scripts/finalize-axebc2-0.1.10-dev.sh + python3 scripts/validate-axebc2-core31-dev.py diff --git a/scripts/finalize-axebc2-0.1.10-dev.sh b/scripts/finalize-axebc2-0.1.10-dev.sh new file mode 100755 index 0000000..f35dd60 --- /dev/null +++ b/scripts/finalize-axebc2-0.1.10-dev.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +if [[ "$#" -lt 3 || "$#" -gt 4 ]]; then + echo "usage: $0 APP_INDEX_DIGEST CORE_CANDIDATE_TAG CORE_INDEX_DIGEST [EVIDENCE_OUTPUT]" >&2 + exit 64 +fi +app_digest="$1"; core_candidate_tag="$2"; core_digest="$3" +repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +compose="$repo_root/willitmod-dev-bc2/docker-compose.yml" +evidence_output="${4:-$repo_root/willitmod-dev-bc2/DEV-ACCEPTANCE-EVIDENCE.json}" +docker_bin="${DOCKER_BIN:-docker}" +app_tag="ghcr.io/willitmod/axebc2-app-umbrel-dev:0.1.10-candidate.6e4ef58218e8" +app_revision="6e4ef58218e8cd5a4d1113196f9872a7f501f52e" +core_tag="ghcr.io/willitmod/bitcoinii-core:$core_candidate_tag" +fail() { echo "ERROR: $*" >&2; exit 1; } +[[ "$app_digest" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "app digest is not an exact sha256 digest" +[[ "$core_digest" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "Core digest is not an exact sha256 digest" +[[ "$core_candidate_tag" =~ ^31\.1\.0-candidate\.[0-9a-f]{12}$ ]] || fail "Core tag must be 31.1.0-candidate.<12 hex>" +command -v "$docker_bin" >/dev/null 2>&1 || fail "Docker is required for registry verification" + +anon_config="$(mktemp -d "${TMPDIR:-/tmp}/axebc2-anonymous-docker.XXXXXX")" +cleanup() { rm -rf -- "$anon_config"; } +trap cleanup EXIT +printf '{"auths":{}}\n' >"$anon_config/config.json" + +resolve_tag() { + local ref="$1" expected="$2" output resolved + [[ "$ref" =~ :[0-9]+\.[0-9]+\.[0-9]+-candidate\.[0-9a-f]{12}$ ]] || fail "not an exact candidate tag: $ref" + output="$("$docker_bin" --config "$anon_config" buildx imagetools inspect "$ref")" || fail "anonymous resolution failed: $ref" + resolved="$(printf '%s\n' "$output" | awk '$1 == "Digest:" {print $2; exit}')" + [[ "$resolved" == "$expected" ]] || fail "$ref resolves to ${resolved:-nothing}, expected $expected" +} +verify_index() { + local ref="$1" digest="$2" manifest + manifest="$("$docker_bin" --config "$anon_config" manifest inspect "$ref@$digest")" || fail "anonymous inspection failed: $ref@$digest" + python3 -c ' +import json,sys +d=json.load(sys.stdin); p={(m.get("platform",{}).get("os"),m.get("platform",{}).get("architecture")) for m in d.get("manifests",[])} +missing={("linux","amd64"),("linux","arm64")}-p +if missing: raise SystemExit("missing required platforms: "+str(sorted(missing))) +' <<<"$manifest" || fail "$ref@$digest is not an amd64+arm64 index" + "$docker_bin" --config "$anon_config" pull --platform linux/amd64 "$ref@$digest" >/dev/null || fail "anonymous amd64 pull failed" + "$docker_bin" --config "$anon_config" pull --platform linux/arm64 "$ref@$digest" >/dev/null || fail "anonymous arm64 pull failed" +} +resolve_tag "$app_tag" "$app_digest"; resolve_tag "$core_tag" "$core_digest" +verify_index "$app_tag" "$app_digest"; verify_index "$core_tag" "$core_digest" + +[[ "$(grep -oF APP_CANDIDATE_DIGEST_REQUIRED "$compose" | wc -l | tr -d ' ')" == 1 ]] || fail "expected one app sentinel" +[[ "$(grep -oF CORE31_CANDIDATE_DIGEST_REQUIRED "$compose" | wc -l | tr -d ' ')" == 2 ]] || fail "expected two Core sentinels" +tmp="$(mktemp "${compose}.finalize.XXXXXX")" +sed -e "s/APP_CANDIDATE_DIGEST_REQUIRED/${app_digest#sha256:}/g" \ + -e "s|ghcr.io/willitmod/bitcoinii-core:31.1.0-dev@sha256:CORE31_CANDIDATE_DIGEST_REQUIRED|$core_tag@$core_digest|g" "$compose" >"$tmp" +chmod 0644 "$tmp" +grep -F _DIGEST_REQUIRED "$tmp" >/dev/null && fail "unresolved digest sentinel remains" +[[ "$(grep -oF "$core_tag@$core_digest" "$tmp" | wc -l | tr -d ' ')" == 2 ]] || fail "Core references differ" +grep -Fx " image: $app_tag@$app_digest" "$tmp" >/dev/null || fail "app reference is incorrect" +grep -Fx " image: $core_tag@$core_digest" "$tmp" >/dev/null || fail "Core service reference is incorrect" +grep -Fx " BTC2D_IMAGE: \"$core_tag@$core_digest\"" "$tmp" >/dev/null || fail "BTC2D_IMAGE is incorrect" + +evidence_tmp="$(mktemp "${evidence_output}.finalize.XXXXXX")" +python3 - "$evidence_tmp" "$app_tag" "$app_digest" "$app_revision" "$core_tag" "$core_digest" <<'PY' +import json,sys +path,app_image,app_digest,revision,core_image,core_digest=sys.argv[1:] +with open(path,"w",encoding="utf-8") as h: + json.dump({"schema":1,"result":"RECORD_passed_AFTER_LIVE_DEV_ACCEPTANCE","app_image":app_image,"app_digest":app_digest,"core_image":core_image,"core_digest":core_digest,"app_version":"0.1.10-dev","source_revision":revision,"tested_on":"RECORD_TEST_NODE","tested_at":"RECORD_ISO_8601_TIMESTAMP","checks":["RECORD_COMPLETED_LIVE_DEV_ACCEPTANCE_CHECKS"]},h,indent=2); h.write("\n") +PY +chmod 0644 "$evidence_tmp" +mv -f "$tmp" "$compose"; mv -f "$evidence_tmp" "$evidence_output" +printf 'Prepared AxeBC2 0.1.10 DEV\napp=%s\ncore=%s\nevidence template=%s\n' "$app_digest" "$core_digest" "$evidence_output" diff --git a/scripts/validate-axebc2-core31-dev.py b/scripts/validate-axebc2-core31-dev.py index b1307f8..94b7531 100644 --- a/scripts/validate-axebc2-core31-dev.py +++ b/scripts/validate-axebc2-core31-dev.py @@ -24,6 +24,12 @@ def require(condition, message): manifest = (APP / "umbrel-app.yml").read_text(encoding="utf-8") node_config = (APP / "data/templates/bitcoinII.conf.template").read_text(encoding="utf-8") +require( + "ghcr.io/willitmod/axebc2-app-umbrel-dev:0.1.10-candidate.6e4ef58218e8@sha256:APP_CANDIDATE_DIGEST_REQUIRED" + in compose, + "DEV app must retain the exact reviewed candidate tag and pending digest", +) + require('version: "0.1.10-dev"' in manifest, "manifest must be 0.1.10-dev") require("Requires 5tratumOS 0.7.11" in manifest, "OS prerequisite must be disclosed") require('"2345:3333/tcp"' in compose, "Stratum host port 2345 must be retained") diff --git a/tests/test_axebc2_dev_finalizer.py b/tests/test_axebc2_dev_finalizer.py new file mode 100644 index 0000000..8552be7 --- /dev/null +++ b/tests/test_axebc2_dev_finalizer.py @@ -0,0 +1,67 @@ +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts/finalize-axebc2-0.1.10-dev.sh" +COMPOSE = ROOT / "willitmod-dev-bc2/docker-compose.yml" +APP_DIGEST = "sha256:" + "a" * 64 +CORE_DIGEST = "sha256:" + "b" * 64 +CORE_TAG = "31.1.0-candidate.123456789abc" + +class AxeBC2DevFinalizerTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="axebc2-dev-finalizer-") + self.root = Path(self.temp.name) + (self.root / "scripts").mkdir(); (self.root / "willitmod-dev-bc2").mkdir() + shutil.copy2(SCRIPT, self.root / "scripts" / SCRIPT.name) + shutil.copy2(COMPOSE, self.root / "willitmod-dev-bc2/docker-compose.yml") + self.original = (self.root / "willitmod-dev-bc2/docker-compose.yml").read_bytes() + self.log = self.root / "docker.log" + self.fake = self.root / "docker" + self.fake.write_text("""#!/bin/sh +set -eu +printf '%s\\n' "$*" >>"$FAKE_DOCKER_LOG" +config="$2"; [ "$1" = --config ]; [ "$(cat "$config/config.json")" = '{"auths":{}}' ]; shift 2 +if [ "$1 $2" = 'buildx imagetools' ]; then + case "$4" in + ghcr.io/willitmod/axebc2-app-umbrel-dev:0.1.10-candidate.6e4ef58218e8) printf 'Digest: %s\\n' "$APP_DIGEST" ;; + ghcr.io/willitmod/bitcoinii-core:31.1.0-candidate.123456789abc) printf 'Digest: %s\\n' "$CORE_DIGEST" ;; + *) exit 2 ;; + esac +elif [ "$1 $2" = 'manifest inspect' ]; then + printf '%s\\n' '{"manifests":[{"platform":{"os":"linux","architecture":"amd64"}},{"platform":{"os":"linux","architecture":"arm64"}}]}' +elif [ "$1" = pull ]; then exit 0 +else exit 3 +fi +""", encoding="utf-8") + self.fake.chmod(0o755) + + def tearDown(self): self.temp.cleanup() + + def run_it(self, core_tag=CORE_TAG): + env=os.environ.copy(); env.update({"DOCKER_BIN":str(self.fake),"FAKE_DOCKER_LOG":str(self.log),"APP_DIGEST":APP_DIGEST,"CORE_DIGEST":CORE_DIGEST}) + return subprocess.run([str(self.root/"scripts"/SCRIPT.name),APP_DIGEST,core_tag,CORE_DIGEST],env=env,text=True,capture_output=True,check=False) + + def test_anonymous_candidate_checks_finalize_and_emit_evidence(self): + result=self.run_it(); self.assertEqual(result.returncode,0,result.stderr) + compose=(self.root/"willitmod-dev-bc2/docker-compose.yml").read_text(encoding="utf-8") + self.assertNotIn("_DIGEST_REQUIRED",compose) + core_ref="ghcr.io/willitmod/bitcoinii-core:"+CORE_TAG+"@"+CORE_DIGEST + self.assertEqual(compose.count(core_ref),2) + evidence=json.loads((self.root/"willitmod-dev-bc2/DEV-ACCEPTANCE-EVIDENCE.json").read_text(encoding="utf-8")) + self.assertEqual(evidence["source_revision"],"6e4ef58218e8cd5a4d1113196f9872a7f501f52e") + self.assertEqual(evidence["app_digest"],APP_DIGEST); self.assertEqual(evidence["core_digest"],CORE_DIGEST) + calls=self.log.read_text(encoding="utf-8") + self.assertEqual(calls.count("--platform linux/amd64"),2); self.assertEqual(calls.count("--platform linux/arm64"),2) + + def test_non_candidate_core_tag_fails_before_registry_and_mutation(self): + result=self.run_it("31.1.0-dev"); self.assertNotEqual(result.returncode,0) + self.assertFalse(self.log.exists()) + self.assertEqual((self.root/"willitmod-dev-bc2/docker-compose.yml").read_bytes(),self.original) + +if __name__ == "__main__": unittest.main() diff --git a/willitmod-dev-bc2/CORE31-DEV-RELEASE.md b/willitmod-dev-bc2/CORE31-DEV-RELEASE.md index 36512a6..bc53d4c 100644 --- a/willitmod-dev-bc2/CORE31-DEV-RELEASE.md +++ b/willitmod-dev-bc2/CORE31-DEV-RELEASE.md @@ -26,6 +26,14 @@ all images must pull anonymously by digest, init must complete successfully on 5tratumOS 0.7.11+, and the resulting installation must be tested on DEV before any production promotion. +Run `scripts/finalize-axebc2-0.1.10-dev.sh` with the exact application index +digest, exact Core candidate tag and exact Core index digest. The application +candidate is fixed to `0.1.10-candidate.6e4ef58218e8` from source revision +`6e4ef58218e8cd5a4d1113196f9872a7f501f52e`. Before editing Compose, the +finalizer anonymously verifies candidate resolution, amd64 and arm64 manifests +and pulls. It atomically replaces every sentinel and emits the evidence JSON +template that must be completed only after live DEV acceptance. + The store validator exercises a pinned copy of the relevant 5tratumOS materialization contract from platform commit `4f979cb9541622c1fdccdf43b8a885bbf845ba38`: it consumes `app_proxy`, publishes the manifest port on the resolved app diff --git a/willitmod-dev-bc2/docker-compose.yml b/willitmod-dev-bc2/docker-compose.yml index cf68f4e..b9208af 100644 --- a/willitmod-dev-bc2/docker-compose.yml +++ b/willitmod-dev-bc2/docker-compose.yml @@ -110,7 +110,7 @@ services: fi app: - image: ghcr.io/willitmod/axebc2-app-umbrel-dev:0.1.10-dev@sha256:APP_CANDIDATE_DIGEST_REQUIRED + image: ghcr.io/willitmod/axebc2-app-umbrel-dev:0.1.10-candidate.6e4ef58218e8@sha256:APP_CANDIDATE_DIGEST_REQUIRED user: "1000:1000" restart: on-failure stop_grace_period: 30s From 170553e12e3225645f6736b6b0ee6c4ae141cb79 Mon Sep 17 00:00:00 2001 From: Johnny Murray Date: Wed, 2 Sep 2026 20:25:23 +0100 Subject: [PATCH 06/18] Bind AxeBC2 DEV to exact Core RC --- scripts/finalize-axebc2-0.1.10-dev.sh | 11 ++++++----- tests/test_axebc2_dev_finalizer.py | 9 +++++---- willitmod-dev-bc2/CORE31-DEV-RELEASE.md | 9 ++++++--- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/scripts/finalize-axebc2-0.1.10-dev.sh b/scripts/finalize-axebc2-0.1.10-dev.sh index f35dd60..272a45b 100755 --- a/scripts/finalize-axebc2-0.1.10-dev.sh +++ b/scripts/finalize-axebc2-0.1.10-dev.sh @@ -12,11 +12,12 @@ evidence_output="${4:-$repo_root/willitmod-dev-bc2/DEV-ACCEPTANCE-EVIDENCE.json} docker_bin="${DOCKER_BIN:-docker}" app_tag="ghcr.io/willitmod/axebc2-app-umbrel-dev:0.1.10-candidate.6e4ef58218e8" app_revision="6e4ef58218e8cd5a4d1113196f9872a7f501f52e" +core_revision="d2d53fb1bd307e2ec464fd752255cbc78023efbd" core_tag="ghcr.io/willitmod/bitcoinii-core:$core_candidate_tag" fail() { echo "ERROR: $*" >&2; exit 1; } [[ "$app_digest" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "app digest is not an exact sha256 digest" [[ "$core_digest" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "Core digest is not an exact sha256 digest" -[[ "$core_candidate_tag" =~ ^31\.1\.0-candidate\.[0-9a-f]{12}$ ]] || fail "Core tag must be 31.1.0-candidate.<12 hex>" +[[ "$core_candidate_tag" == "31.1.0-rc.d2d53fb1bd30" ]] || fail "Core tag must be 31.1.0-rc.d2d53fb1bd30" command -v "$docker_bin" >/dev/null 2>&1 || fail "Docker is required for registry verification" anon_config="$(mktemp -d "${TMPDIR:-/tmp}/axebc2-anonymous-docker.XXXXXX")" @@ -26,7 +27,7 @@ printf '{"auths":{}}\n' >"$anon_config/config.json" resolve_tag() { local ref="$1" expected="$2" output resolved - [[ "$ref" =~ :[0-9]+\.[0-9]+\.[0-9]+-candidate\.[0-9a-f]{12}$ ]] || fail "not an exact candidate tag: $ref" + [[ "$ref" == "$app_tag" || "$ref" == "$core_tag" ]] || fail "not an approved candidate tag: $ref" output="$("$docker_bin" --config "$anon_config" buildx imagetools inspect "$ref")" || fail "anonymous resolution failed: $ref" resolved="$(printf '%s\n' "$output" | awk '$1 == "Digest:" {print $2; exit}')" [[ "$resolved" == "$expected" ]] || fail "$ref resolves to ${resolved:-nothing}, expected $expected" @@ -59,11 +60,11 @@ grep -Fx " image: $core_tag@$core_digest" "$tmp" >/dev/null || fail "Core ser grep -Fx " BTC2D_IMAGE: \"$core_tag@$core_digest\"" "$tmp" >/dev/null || fail "BTC2D_IMAGE is incorrect" evidence_tmp="$(mktemp "${evidence_output}.finalize.XXXXXX")" -python3 - "$evidence_tmp" "$app_tag" "$app_digest" "$app_revision" "$core_tag" "$core_digest" <<'PY' +python3 - "$evidence_tmp" "$app_tag" "$app_digest" "$app_revision" "$core_tag" "$core_digest" "$core_revision" <<'PY' import json,sys -path,app_image,app_digest,revision,core_image,core_digest=sys.argv[1:] +path,app_image,app_digest,revision,core_image,core_digest,core_revision=sys.argv[1:] with open(path,"w",encoding="utf-8") as h: - json.dump({"schema":1,"result":"RECORD_passed_AFTER_LIVE_DEV_ACCEPTANCE","app_image":app_image,"app_digest":app_digest,"core_image":core_image,"core_digest":core_digest,"app_version":"0.1.10-dev","source_revision":revision,"tested_on":"RECORD_TEST_NODE","tested_at":"RECORD_ISO_8601_TIMESTAMP","checks":["RECORD_COMPLETED_LIVE_DEV_ACCEPTANCE_CHECKS"]},h,indent=2); h.write("\n") + json.dump({"schema":1,"result":"RECORD_passed_AFTER_LIVE_DEV_ACCEPTANCE","app_image":app_image,"app_digest":app_digest,"core_image":core_image,"core_digest":core_digest,"app_version":"0.1.10-dev","source_revision":revision,"core_source_revision":core_revision,"tested_on":"RECORD_TEST_NODE","tested_at":"RECORD_ISO_8601_TIMESTAMP","checks":["RECORD_COMPLETED_LIVE_DEV_ACCEPTANCE_CHECKS"]},h,indent=2); h.write("\n") PY chmod 0644 "$evidence_tmp" mv -f "$tmp" "$compose"; mv -f "$evidence_tmp" "$evidence_output" diff --git a/tests/test_axebc2_dev_finalizer.py b/tests/test_axebc2_dev_finalizer.py index 8552be7..73c3548 100644 --- a/tests/test_axebc2_dev_finalizer.py +++ b/tests/test_axebc2_dev_finalizer.py @@ -11,7 +11,7 @@ COMPOSE = ROOT / "willitmod-dev-bc2/docker-compose.yml" APP_DIGEST = "sha256:" + "a" * 64 CORE_DIGEST = "sha256:" + "b" * 64 -CORE_TAG = "31.1.0-candidate.123456789abc" +CORE_TAG = "31.1.0-rc.d2d53fb1bd30" class AxeBC2DevFinalizerTests(unittest.TestCase): def setUp(self): @@ -30,7 +30,7 @@ def setUp(self): if [ "$1 $2" = 'buildx imagetools' ]; then case "$4" in ghcr.io/willitmod/axebc2-app-umbrel-dev:0.1.10-candidate.6e4ef58218e8) printf 'Digest: %s\\n' "$APP_DIGEST" ;; - ghcr.io/willitmod/bitcoinii-core:31.1.0-candidate.123456789abc) printf 'Digest: %s\\n' "$CORE_DIGEST" ;; + ghcr.io/willitmod/bitcoinii-core:31.1.0-rc.d2d53fb1bd30) printf 'Digest: %s\\n' "$CORE_DIGEST" ;; *) exit 2 ;; esac elif [ "$1 $2" = 'manifest inspect' ]; then @@ -55,12 +55,13 @@ def test_anonymous_candidate_checks_finalize_and_emit_evidence(self): self.assertEqual(compose.count(core_ref),2) evidence=json.loads((self.root/"willitmod-dev-bc2/DEV-ACCEPTANCE-EVIDENCE.json").read_text(encoding="utf-8")) self.assertEqual(evidence["source_revision"],"6e4ef58218e8cd5a4d1113196f9872a7f501f52e") + self.assertEqual(evidence["core_source_revision"],"d2d53fb1bd307e2ec464fd752255cbc78023efbd") self.assertEqual(evidence["app_digest"],APP_DIGEST); self.assertEqual(evidence["core_digest"],CORE_DIGEST) calls=self.log.read_text(encoding="utf-8") self.assertEqual(calls.count("--platform linux/amd64"),2); self.assertEqual(calls.count("--platform linux/arm64"),2) - def test_non_candidate_core_tag_fails_before_registry_and_mutation(self): - result=self.run_it("31.1.0-dev"); self.assertNotEqual(result.returncode,0) + def test_wrong_core_revision_tag_fails_before_registry_and_mutation(self): + result=self.run_it("31.1.0-rc.000000000000"); self.assertNotEqual(result.returncode,0) self.assertFalse(self.log.exists()) self.assertEqual((self.root/"willitmod-dev-bc2/docker-compose.yml").read_bytes(),self.original) diff --git a/willitmod-dev-bc2/CORE31-DEV-RELEASE.md b/willitmod-dev-bc2/CORE31-DEV-RELEASE.md index bc53d4c..131996a 100644 --- a/willitmod-dev-bc2/CORE31-DEV-RELEASE.md +++ b/willitmod-dev-bc2/CORE31-DEV-RELEASE.md @@ -29,10 +29,13 @@ any production promotion. Run `scripts/finalize-axebc2-0.1.10-dev.sh` with the exact application index digest, exact Core candidate tag and exact Core index digest. The application candidate is fixed to `0.1.10-candidate.6e4ef58218e8` from source revision -`6e4ef58218e8cd5a4d1113196f9872a7f501f52e`. Before editing Compose, the +`6e4ef58218e8cd5a4d1113196f9872a7f501f52e`. The Core candidate is fixed to +`31.1.0-rc.d2d53fb1bd30` from source revision +`d2d53fb1bd307e2ec464fd752255cbc78023efbd`. Before editing Compose, the finalizer anonymously verifies candidate resolution, amd64 and arm64 manifests -and pulls. It atomically replaces every sentinel and emits the evidence JSON -template that must be completed only after live DEV acceptance. +and pulls. It atomically replaces every sentinel and emits both exact source +revisions in the evidence JSON template, which must be completed only after +live DEV acceptance. The store validator exercises a pinned copy of the relevant 5tratumOS materialization contract from platform commit `4f979cb9541622c1fdccdf43b8a885bbf845ba38`: From ec006ead3f41702964ab5f7dbed0cb7e52cf47f3 Mon Sep 17 00:00:00 2001 From: Johnny Murray Date: Wed, 2 Sep 2026 20:34:36 +0100 Subject: [PATCH 07/18] Track retried AxeBC2 Core RC --- scripts/finalize-axebc2-0.1.10-dev.sh | 6 +++--- tests/test_axebc2_dev_finalizer.py | 7 ++++--- willitmod-dev-bc2/CORE31-DEV-RELEASE.md | 5 +++-- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/scripts/finalize-axebc2-0.1.10-dev.sh b/scripts/finalize-axebc2-0.1.10-dev.sh index 272a45b..81cf2f1 100755 --- a/scripts/finalize-axebc2-0.1.10-dev.sh +++ b/scripts/finalize-axebc2-0.1.10-dev.sh @@ -12,12 +12,12 @@ evidence_output="${4:-$repo_root/willitmod-dev-bc2/DEV-ACCEPTANCE-EVIDENCE.json} docker_bin="${DOCKER_BIN:-docker}" app_tag="ghcr.io/willitmod/axebc2-app-umbrel-dev:0.1.10-candidate.6e4ef58218e8" app_revision="6e4ef58218e8cd5a4d1113196f9872a7f501f52e" -core_revision="d2d53fb1bd307e2ec464fd752255cbc78023efbd" +core_revision="3c2cafcab19efde33c1e476a982c3389957dacb2" core_tag="ghcr.io/willitmod/bitcoinii-core:$core_candidate_tag" fail() { echo "ERROR: $*" >&2; exit 1; } [[ "$app_digest" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "app digest is not an exact sha256 digest" [[ "$core_digest" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "Core digest is not an exact sha256 digest" -[[ "$core_candidate_tag" == "31.1.0-rc.d2d53fb1bd30" ]] || fail "Core tag must be 31.1.0-rc.d2d53fb1bd30" +[[ "$core_candidate_tag" == "31.1.0-rc.3c2cafcab19e" ]] || fail "Core tag must be 31.1.0-rc.3c2cafcab19e" command -v "$docker_bin" >/dev/null 2>&1 || fail "Docker is required for registry verification" anon_config="$(mktemp -d "${TMPDIR:-/tmp}/axebc2-anonymous-docker.XXXXXX")" @@ -64,7 +64,7 @@ python3 - "$evidence_tmp" "$app_tag" "$app_digest" "$app_revision" "$core_tag" " import json,sys path,app_image,app_digest,revision,core_image,core_digest,core_revision=sys.argv[1:] with open(path,"w",encoding="utf-8") as h: - json.dump({"schema":1,"result":"RECORD_passed_AFTER_LIVE_DEV_ACCEPTANCE","app_image":app_image,"app_digest":app_digest,"core_image":core_image,"core_digest":core_digest,"app_version":"0.1.10-dev","source_revision":revision,"core_source_revision":core_revision,"tested_on":"RECORD_TEST_NODE","tested_at":"RECORD_ISO_8601_TIMESTAMP","checks":["RECORD_COMPLETED_LIVE_DEV_ACCEPTANCE_CHECKS"]},h,indent=2); h.write("\n") + json.dump({"schema":1,"result":"RECORD_passed_AFTER_LIVE_DEV_ACCEPTANCE","app_image":app_image,"app_digest":app_digest,"core_image":core_image,"core_digest":core_digest,"app_version":"0.1.10-dev","source_revision":revision,"core_source_revision":core_revision,"core_candidate_run":33674007419,"tested_on":"RECORD_TEST_NODE","tested_at":"RECORD_ISO_8601_TIMESTAMP","checks":["RECORD_COMPLETED_LIVE_DEV_ACCEPTANCE_CHECKS"]},h,indent=2); h.write("\n") PY chmod 0644 "$evidence_tmp" mv -f "$tmp" "$compose"; mv -f "$evidence_tmp" "$evidence_output" diff --git a/tests/test_axebc2_dev_finalizer.py b/tests/test_axebc2_dev_finalizer.py index 73c3548..6526123 100644 --- a/tests/test_axebc2_dev_finalizer.py +++ b/tests/test_axebc2_dev_finalizer.py @@ -11,7 +11,7 @@ COMPOSE = ROOT / "willitmod-dev-bc2/docker-compose.yml" APP_DIGEST = "sha256:" + "a" * 64 CORE_DIGEST = "sha256:" + "b" * 64 -CORE_TAG = "31.1.0-rc.d2d53fb1bd30" +CORE_TAG = "31.1.0-rc.3c2cafcab19e" class AxeBC2DevFinalizerTests(unittest.TestCase): def setUp(self): @@ -30,7 +30,7 @@ def setUp(self): if [ "$1 $2" = 'buildx imagetools' ]; then case "$4" in ghcr.io/willitmod/axebc2-app-umbrel-dev:0.1.10-candidate.6e4ef58218e8) printf 'Digest: %s\\n' "$APP_DIGEST" ;; - ghcr.io/willitmod/bitcoinii-core:31.1.0-rc.d2d53fb1bd30) printf 'Digest: %s\\n' "$CORE_DIGEST" ;; + ghcr.io/willitmod/bitcoinii-core:31.1.0-rc.3c2cafcab19e) printf 'Digest: %s\\n' "$CORE_DIGEST" ;; *) exit 2 ;; esac elif [ "$1 $2" = 'manifest inspect' ]; then @@ -55,7 +55,8 @@ def test_anonymous_candidate_checks_finalize_and_emit_evidence(self): self.assertEqual(compose.count(core_ref),2) evidence=json.loads((self.root/"willitmod-dev-bc2/DEV-ACCEPTANCE-EVIDENCE.json").read_text(encoding="utf-8")) self.assertEqual(evidence["source_revision"],"6e4ef58218e8cd5a4d1113196f9872a7f501f52e") - self.assertEqual(evidence["core_source_revision"],"d2d53fb1bd307e2ec464fd752255cbc78023efbd") + self.assertEqual(evidence["core_source_revision"],"3c2cafcab19efde33c1e476a982c3389957dacb2") + self.assertEqual(evidence["core_candidate_run"],33674007419) self.assertEqual(evidence["app_digest"],APP_DIGEST); self.assertEqual(evidence["core_digest"],CORE_DIGEST) calls=self.log.read_text(encoding="utf-8") self.assertEqual(calls.count("--platform linux/amd64"),2); self.assertEqual(calls.count("--platform linux/arm64"),2) diff --git a/willitmod-dev-bc2/CORE31-DEV-RELEASE.md b/willitmod-dev-bc2/CORE31-DEV-RELEASE.md index 131996a..54d0744 100644 --- a/willitmod-dev-bc2/CORE31-DEV-RELEASE.md +++ b/willitmod-dev-bc2/CORE31-DEV-RELEASE.md @@ -30,8 +30,9 @@ Run `scripts/finalize-axebc2-0.1.10-dev.sh` with the exact application index digest, exact Core candidate tag and exact Core index digest. The application candidate is fixed to `0.1.10-candidate.6e4ef58218e8` from source revision `6e4ef58218e8cd5a4d1113196f9872a7f501f52e`. The Core candidate is fixed to -`31.1.0-rc.d2d53fb1bd30` from source revision -`d2d53fb1bd307e2ec464fd752255cbc78023efbd`. Before editing Compose, the +`31.1.0-rc.3c2cafcab19e` from source revision +`3c2cafcab19efde33c1e476a982c3389957dacb2`, candidate workflow run +`33674007419`. Before editing Compose, the finalizer anonymously verifies candidate resolution, amd64 and arm64 manifests and pulls. It atomically replaces every sentinel and emits both exact source revisions in the evidence JSON template, which must be completed only after From b8fb692197b5b51f3f3252a7d96b94e926b87c9f Mon Sep 17 00:00:00 2001 From: Johnny Murray Date: Wed, 2 Sep 2026 20:45:22 +0100 Subject: [PATCH 08/18] Validate AxeBC2 DEV release lifecycle --- .../workflows/validate-axebc2-core31-dev.yml | 8 ++++++- scripts/axebc2_release_state.py | 22 +++++++++++++++++++ scripts/finalize-axebc2-0.1.10-dev.sh | 2 +- scripts/validate-axebc2-core31-dev.py | 18 +++++++-------- tests/test_axebc2_release_state.py | 17 ++++++++++++++ willitmod-dev-bc2/CORE31-DEV-RELEASE.md | 5 +++++ willitmod-dev-bc2/docker-compose.yml | 4 ++-- 7 files changed, 63 insertions(+), 13 deletions(-) create mode 100644 scripts/axebc2_release_state.py create mode 100644 tests/test_axebc2_release_state.py diff --git a/.github/workflows/validate-axebc2-core31-dev.yml b/.github/workflows/validate-axebc2-core31-dev.yml index b3564e3..ba030b5 100644 --- a/.github/workflows/validate-axebc2-core31-dev.yml +++ b/.github/workflows/validate-axebc2-core31-dev.yml @@ -7,8 +7,10 @@ on: - "tests/test_axebc2_core31_init.py" - "tests/test_axebc2_platform_integration.py" - "tests/test_axebc2_dev_finalizer.py" + - "tests/test_axebc2_release_state.py" - "tests/fixtures/5tratumos_contract_4f979cb.py" - "scripts/validate-axebc2-core31-dev.py" + - "scripts/axebc2_release_state.py" - "scripts/finalize-axebc2-0.1.10-dev.sh" - ".github/workflows/validate-axebc2-core31-dev.yml" push: @@ -18,8 +20,10 @@ on: - "tests/test_axebc2_core31_init.py" - "tests/test_axebc2_platform_integration.py" - "tests/test_axebc2_dev_finalizer.py" + - "tests/test_axebc2_release_state.py" - "tests/fixtures/5tratumos_contract_4f979cb.py" - "scripts/validate-axebc2-core31-dev.py" + - "scripts/axebc2_release_state.py" - "scripts/finalize-axebc2-0.1.10-dev.sh" - ".github/workflows/validate-axebc2-core31-dev.yml" @@ -39,4 +43,6 @@ jobs: - name: Validate metadata and migration initialization run: | bash -n scripts/finalize-axebc2-0.1.10-dev.sh - python3 scripts/validate-axebc2-core31-dev.py + count="$(awk '{n += gsub(/_DIGEST_REQUIRED/, "")} END {print n + 0}' willitmod-dev-bc2/docker-compose.yml)" + if [ "$count" = 3 ]; then phase=prefinalization; elif [ "$count" = 0 ]; then phase=finalized; else echo "partial digest finalization" >&2; exit 1; fi + python3 scripts/validate-axebc2-core31-dev.py --phase "$phase" diff --git a/scripts/axebc2_release_state.py b/scripts/axebc2_release_state.py new file mode 100644 index 0000000..87656bb --- /dev/null +++ b/scripts/axebc2_release_state.py @@ -0,0 +1,22 @@ +import re + +APP_TAG = "ghcr.io/willitmod/axebc2-app-umbrel-dev:0.1.10-candidate.6e4ef58218e8" +CORE_TAG = "ghcr.io/willitmod/bitcoinii-core:31.1.0-rc.3c2cafcab19e" + +def validate(compose, phase): + if phase not in {"prefinalization", "finalized"}: + raise ValueError("phase must be prefinalization or finalized") + app_sentinel = APP_TAG + "@sha256:APP_CANDIDATE_DIGEST_REQUIRED" + core_sentinel = CORE_TAG + "@sha256:CORE31_CANDIDATE_DIGEST_REQUIRED" + if phase == "prefinalization": + if compose.count(app_sentinel) != 1 or compose.count(core_sentinel) != 2: + raise ValueError("prefinalization requires the exact three digest sentinels") + if compose.count("_DIGEST_REQUIRED") != 3: + raise ValueError("unknown or partial digest sentinel state") + return + if "_DIGEST_REQUIRED" in compose: + raise ValueError("finalized release contains a digest sentinel") + app = re.findall(re.escape(APP_TAG) + r"@(sha256:[0-9a-f]{64})", compose) + core = re.findall(re.escape(CORE_TAG) + r"@(sha256:[0-9a-f]{64})", compose) + if len(app) != 1 or len(core) != 2 or len(set(core)) != 1: + raise ValueError("finalized release requires one app pin and two identical Core pins") diff --git a/scripts/finalize-axebc2-0.1.10-dev.sh b/scripts/finalize-axebc2-0.1.10-dev.sh index 81cf2f1..53efbca 100755 --- a/scripts/finalize-axebc2-0.1.10-dev.sh +++ b/scripts/finalize-axebc2-0.1.10-dev.sh @@ -51,7 +51,7 @@ verify_index "$app_tag" "$app_digest"; verify_index "$core_tag" "$core_digest" [[ "$(grep -oF CORE31_CANDIDATE_DIGEST_REQUIRED "$compose" | wc -l | tr -d ' ')" == 2 ]] || fail "expected two Core sentinels" tmp="$(mktemp "${compose}.finalize.XXXXXX")" sed -e "s/APP_CANDIDATE_DIGEST_REQUIRED/${app_digest#sha256:}/g" \ - -e "s|ghcr.io/willitmod/bitcoinii-core:31.1.0-dev@sha256:CORE31_CANDIDATE_DIGEST_REQUIRED|$core_tag@$core_digest|g" "$compose" >"$tmp" + -e "s|$core_tag@sha256:CORE31_CANDIDATE_DIGEST_REQUIRED|$core_tag@$core_digest|g" "$compose" >"$tmp" chmod 0644 "$tmp" grep -F _DIGEST_REQUIRED "$tmp" >/dev/null && fail "unresolved digest sentinel remains" [[ "$(grep -oF "$core_tag@$core_digest" "$tmp" | wc -l | tr -d ' ')" == 2 ]] || fail "Core references differ" diff --git a/scripts/validate-axebc2-core31-dev.py b/scripts/validate-axebc2-core31-dev.py index 94b7531..a0616dd 100644 --- a/scripts/validate-axebc2-core31-dev.py +++ b/scripts/validate-axebc2-core31-dev.py @@ -9,6 +9,8 @@ import sys import tempfile import unittest +import argparse +from axebc2_release_state import validate as validate_release_state ROOT = Path(__file__).resolve().parents[1] @@ -21,15 +23,16 @@ def require(condition, message): compose = (APP / "docker-compose.yml").read_text(encoding="utf-8") +parser = argparse.ArgumentParser() +parser.add_argument("--phase", required=True, choices=("prefinalization", "finalized")) +phase = parser.parse_args().phase +try: + validate_release_state(compose, phase) +except ValueError as exc: + raise SystemExit(str(exc)) manifest = (APP / "umbrel-app.yml").read_text(encoding="utf-8") node_config = (APP / "data/templates/bitcoinII.conf.template").read_text(encoding="utf-8") -require( - "ghcr.io/willitmod/axebc2-app-umbrel-dev:0.1.10-candidate.6e4ef58218e8@sha256:APP_CANDIDATE_DIGEST_REQUIRED" - in compose, - "DEV app must retain the exact reviewed candidate tag and pending digest", -) - require('version: "0.1.10-dev"' in manifest, "manifest must be 0.1.10-dev") require("Requires 5tratumOS 0.7.11" in manifest, "OS prerequisite must be disclosed") require('"2345:3333/tcp"' in compose, "Stratum host port 2345 must be retained") @@ -54,9 +57,6 @@ def require(condition, message): require("natpmp=0" in node_config and "upnp=1" not in node_config, "NAT-PMP must be off") require(not re.search(r'^\s+-\s+"?8338:', compose, re.MULTILINE), "P2P must not be published") -for placeholder in ("CORE31_CANDIDATE_DIGEST_REQUIRED", "APP_CANDIDATE_DIGEST_REQUIRED"): - require(placeholder in compose, f"pending digest sentinel is missing: {placeholder}") - require( compose.count("create_host_path: false") == 9, "every AxeBC2 host bind must disable implicit source-path creation", diff --git a/tests/test_axebc2_release_state.py b/tests/test_axebc2_release_state.py new file mode 100644 index 0000000..ad2e43d --- /dev/null +++ b/tests/test_axebc2_release_state.py @@ -0,0 +1,17 @@ +import sys +from pathlib import Path +import unittest +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) +from axebc2_release_state import APP_TAG, CORE_TAG, validate + +class ReleaseStateTests(unittest.TestCase): + def test_only_complete_prefinalization_is_accepted(self): + text=f"{APP_TAG}@sha256:APP_CANDIDATE_DIGEST_REQUIRED\n{CORE_TAG}@sha256:CORE31_CANDIDATE_DIGEST_REQUIRED\n{CORE_TAG}@sha256:CORE31_CANDIDATE_DIGEST_REQUIRED" + validate(text,"prefinalization") + with self.assertRaises(ValueError): validate(text.replace("CORE31_CANDIDATE_DIGEST_REQUIRED","a"*64,1),"prefinalization") + def test_only_complete_immutable_finalization_is_accepted(self): + a="sha256:"+"a"*64; c="sha256:"+"c"*64 + text=f"{APP_TAG}@{a}\n{CORE_TAG}@{c}\n{CORE_TAG}@{c}" + validate(text,"finalized") + with self.assertRaises(ValueError): validate(text.replace(c,"sha256:"+"d"*64,1),"finalized") + with self.assertRaises(ValueError): validate(text+"\n_DIGEST_REQUIRED","finalized") diff --git a/willitmod-dev-bc2/CORE31-DEV-RELEASE.md b/willitmod-dev-bc2/CORE31-DEV-RELEASE.md index 54d0744..34e1033 100644 --- a/willitmod-dev-bc2/CORE31-DEV-RELEASE.md +++ b/willitmod-dev-bc2/CORE31-DEV-RELEASE.md @@ -20,6 +20,11 @@ The committed Compose file deliberately retains these non-runnable sentinels: - `CORE31_CANDIDATE_DIGEST_REQUIRED` - `APP_CANDIDATE_DIGEST_REQUIRED` +CI treats this as the strict `prefinalization` phase. It accepts exactly all +three expected sentinel occurrences. Once finalization is committed, CI +switches to `finalized` and requires one immutable application sha256 pin and +two identical immutable Core sha256 pins. A partial or mixed state is rejected. + They must be replaced with the exact verified multi-architecture candidate digests. After substitution, the merged platform Compose must pass validation, all images must pull anonymously by digest, init must complete successfully on diff --git a/willitmod-dev-bc2/docker-compose.yml b/willitmod-dev-bc2/docker-compose.yml index b9208af..6ce665b 100644 --- a/willitmod-dev-bc2/docker-compose.yml +++ b/willitmod-dev-bc2/docker-compose.yml @@ -54,7 +54,7 @@ services: - exec /bin/sh /opt/axebc2/init.sh btc2d: - image: ghcr.io/willitmod/bitcoinii-core:31.1.0-dev@sha256:CORE31_CANDIDATE_DIGEST_REQUIRED + image: ghcr.io/willitmod/bitcoinii-core:31.1.0-rc.3c2cafcab19e@sha256:CORE31_CANDIDATE_DIGEST_REQUIRED user: "1000:1000" restart: unless-stopped stop_grace_period: 15m30s @@ -136,7 +136,7 @@ services: STATIC_DIR: "/app/static" APP_CHANNEL: "ALPHA" APP_VERSION_SUFFIX: "-dev" - BTC2D_IMAGE: "ghcr.io/willitmod/bitcoinii-core:31.1.0-dev@sha256:CORE31_CANDIDATE_DIGEST_REQUIRED" + BTC2D_IMAGE: "ghcr.io/willitmod/bitcoinii-core:31.1.0-rc.3c2cafcab19e@sha256:CORE31_CANDIDATE_DIGEST_REQUIRED" CKPOOL_IMAGE: "ghcr.io/willitmod/docker-ckpool-solo:590fb2a@sha256:8a9a7f10c8138d0f55533132ee7710a06715a42a49f75efb39be3350ada4fa6e" SUPPORT_CHECKIN_ENABLED: "false" BTC2_RPC_HOST: "btc2d" From 08354d4ddb19ac79864122979af1fa04974c83d8 Mon Sep 17 00:00:00 2001 From: Johnny Murray Date: Wed, 2 Sep 2026 20:47:50 +0100 Subject: [PATCH 09/18] Emit structured AxeBC2 DEV acceptance evidence --- scripts/finalize-axebc2-0.1.10-dev.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/finalize-axebc2-0.1.10-dev.sh b/scripts/finalize-axebc2-0.1.10-dev.sh index 53efbca..95bee0e 100755 --- a/scripts/finalize-axebc2-0.1.10-dev.sh +++ b/scripts/finalize-axebc2-0.1.10-dev.sh @@ -64,7 +64,7 @@ python3 - "$evidence_tmp" "$app_tag" "$app_digest" "$app_revision" "$core_tag" " import json,sys path,app_image,app_digest,revision,core_image,core_digest,core_revision=sys.argv[1:] with open(path,"w",encoding="utf-8") as h: - json.dump({"schema":1,"result":"RECORD_passed_AFTER_LIVE_DEV_ACCEPTANCE","app_image":app_image,"app_digest":app_digest,"core_image":core_image,"core_digest":core_digest,"app_version":"0.1.10-dev","source_revision":revision,"core_source_revision":core_revision,"core_candidate_run":33674007419,"tested_on":"RECORD_TEST_NODE","tested_at":"RECORD_ISO_8601_TIMESTAMP","checks":["RECORD_COMPLETED_LIVE_DEV_ACCEPTANCE_CHECKS"]},h,indent=2); h.write("\n") + json.dump({"schema":1,"result":"RECORD_passed_AFTER_LIVE_DEV_ACCEPTANCE","app_image":app_image,"app_digest":app_digest,"core_image":core_image,"core_digest":core_digest,"app_version":"0.1.10-dev","source_revision":revision,"core_source_revision":core_revision,"core_candidate_run":33674007419,"tested_on":"RECORD_TEST_NODE","tested_at":"RECORD_ISO_8601_TIMESTAMP","acceptance":{"observed_at":"RECORD_ISO_8601_TIMESTAMP","core_version":"RECORD_INTEGER_VERSION","migration_required_marker_absent":"RECORD_BOOLEAN","migration_complete_marker_valid":"RECORD_BOOLEAN","checkpoint_height":57752,"checkpoint_hash":"000000000000000013ceffe797280c57f75a5b9f1d9e70c3503584058c322576","chainwork":"RECORD_64_HEX_CHAINWORK","ibd":False,"verification_progress":"RECORD_NUMBER","blocks":"RECORD_INTEGER","headers":"RECORD_SAME_INTEGER","best_block_hash":"RECORD_64_HEX_HASH","explorer_common_height":"RECORD_SAME_INTEGER","explorer_common_hash":"RECORD_SAME_64_HEX_HASH","outbound_core31_peers":"RECORD_INTEGER_AT_LEAST_3","verifychain_level":4,"verifychain_passed":"RECORD_BOOLEAN","payout_configured":"RECORD_BOOLEAN","payout_preserved":"RECORD_BOOLEAN","pool_stratum_result":"RECORD_passed","app_ui_privacy_passed":"RECORD_BOOLEAN","telemetry_disabled":"RECORD_BOOLEAN","app_rollback_rejected":"RECORD_BOOLEAN","os_rollback_rejected":"RECORD_BOOLEAN"}},h,indent=2); h.write("\n") PY chmod 0644 "$evidence_tmp" mv -f "$tmp" "$compose"; mv -f "$evidence_tmp" "$evidence_output" From ead30979466ee8ec053b1ab6f37aa0c8c611035d Mon Sep 17 00:00:00 2001 From: Johnny Murray Date: Wed, 2 Sep 2026 20:49:39 +0100 Subject: [PATCH 10/18] Rotate AxeBC2 DEV to successful Core candidate --- scripts/axebc2_release_state.py | 2 +- scripts/finalize-axebc2-0.1.10-dev.sh | 6 +++--- tests/test_axebc2_dev_finalizer.py | 8 ++++---- willitmod-dev-bc2/CORE31-DEV-RELEASE.md | 6 +++--- willitmod-dev-bc2/docker-compose.yml | 4 ++-- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/scripts/axebc2_release_state.py b/scripts/axebc2_release_state.py index 87656bb..e5d10fb 100644 --- a/scripts/axebc2_release_state.py +++ b/scripts/axebc2_release_state.py @@ -1,7 +1,7 @@ import re APP_TAG = "ghcr.io/willitmod/axebc2-app-umbrel-dev:0.1.10-candidate.6e4ef58218e8" -CORE_TAG = "ghcr.io/willitmod/bitcoinii-core:31.1.0-rc.3c2cafcab19e" +CORE_TAG = "ghcr.io/willitmod/bitcoinii-core:31.1.0-rc.cdf44542dde2" def validate(compose, phase): if phase not in {"prefinalization", "finalized"}: diff --git a/scripts/finalize-axebc2-0.1.10-dev.sh b/scripts/finalize-axebc2-0.1.10-dev.sh index 95bee0e..adc3402 100755 --- a/scripts/finalize-axebc2-0.1.10-dev.sh +++ b/scripts/finalize-axebc2-0.1.10-dev.sh @@ -12,12 +12,12 @@ evidence_output="${4:-$repo_root/willitmod-dev-bc2/DEV-ACCEPTANCE-EVIDENCE.json} docker_bin="${DOCKER_BIN:-docker}" app_tag="ghcr.io/willitmod/axebc2-app-umbrel-dev:0.1.10-candidate.6e4ef58218e8" app_revision="6e4ef58218e8cd5a4d1113196f9872a7f501f52e" -core_revision="3c2cafcab19efde33c1e476a982c3389957dacb2" +core_revision="cdf44542dde255648008249d187fafc15f3a2f09" core_tag="ghcr.io/willitmod/bitcoinii-core:$core_candidate_tag" fail() { echo "ERROR: $*" >&2; exit 1; } [[ "$app_digest" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "app digest is not an exact sha256 digest" [[ "$core_digest" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "Core digest is not an exact sha256 digest" -[[ "$core_candidate_tag" == "31.1.0-rc.3c2cafcab19e" ]] || fail "Core tag must be 31.1.0-rc.3c2cafcab19e" +[[ "$core_candidate_tag" == "31.1.0-rc.cdf44542dde2" ]] || fail "Core tag must be 31.1.0-rc.cdf44542dde2" command -v "$docker_bin" >/dev/null 2>&1 || fail "Docker is required for registry verification" anon_config="$(mktemp -d "${TMPDIR:-/tmp}/axebc2-anonymous-docker.XXXXXX")" @@ -64,7 +64,7 @@ python3 - "$evidence_tmp" "$app_tag" "$app_digest" "$app_revision" "$core_tag" " import json,sys path,app_image,app_digest,revision,core_image,core_digest,core_revision=sys.argv[1:] with open(path,"w",encoding="utf-8") as h: - json.dump({"schema":1,"result":"RECORD_passed_AFTER_LIVE_DEV_ACCEPTANCE","app_image":app_image,"app_digest":app_digest,"core_image":core_image,"core_digest":core_digest,"app_version":"0.1.10-dev","source_revision":revision,"core_source_revision":core_revision,"core_candidate_run":33674007419,"tested_on":"RECORD_TEST_NODE","tested_at":"RECORD_ISO_8601_TIMESTAMP","acceptance":{"observed_at":"RECORD_ISO_8601_TIMESTAMP","core_version":"RECORD_INTEGER_VERSION","migration_required_marker_absent":"RECORD_BOOLEAN","migration_complete_marker_valid":"RECORD_BOOLEAN","checkpoint_height":57752,"checkpoint_hash":"000000000000000013ceffe797280c57f75a5b9f1d9e70c3503584058c322576","chainwork":"RECORD_64_HEX_CHAINWORK","ibd":False,"verification_progress":"RECORD_NUMBER","blocks":"RECORD_INTEGER","headers":"RECORD_SAME_INTEGER","best_block_hash":"RECORD_64_HEX_HASH","explorer_common_height":"RECORD_SAME_INTEGER","explorer_common_hash":"RECORD_SAME_64_HEX_HASH","outbound_core31_peers":"RECORD_INTEGER_AT_LEAST_3","verifychain_level":4,"verifychain_passed":"RECORD_BOOLEAN","payout_configured":"RECORD_BOOLEAN","payout_preserved":"RECORD_BOOLEAN","pool_stratum_result":"RECORD_passed","app_ui_privacy_passed":"RECORD_BOOLEAN","telemetry_disabled":"RECORD_BOOLEAN","app_rollback_rejected":"RECORD_BOOLEAN","os_rollback_rejected":"RECORD_BOOLEAN"}},h,indent=2); h.write("\n") + json.dump({"schema":1,"result":"RECORD_passed_AFTER_LIVE_DEV_ACCEPTANCE","app_image":app_image,"app_digest":app_digest,"core_image":core_image,"core_digest":core_digest,"app_version":"0.1.10-dev","source_revision":revision,"core_source_revision":core_revision,"core_candidate_run":33675068951,"tested_on":"RECORD_TEST_NODE","tested_at":"RECORD_ISO_8601_TIMESTAMP","acceptance":{"observed_at":"RECORD_ISO_8601_TIMESTAMP","core_version":"RECORD_INTEGER_VERSION","migration_required_marker_absent":"RECORD_BOOLEAN","migration_complete_marker_valid":"RECORD_BOOLEAN","checkpoint_height":57752,"checkpoint_hash":"000000000000000013ceffe797280c57f75a5b9f1d9e70c3503584058c322576","chainwork":"RECORD_64_HEX_CHAINWORK","ibd":False,"verification_progress":"RECORD_NUMBER","blocks":"RECORD_INTEGER","headers":"RECORD_SAME_INTEGER","best_block_hash":"RECORD_64_HEX_HASH","explorer_common_height":"RECORD_SAME_INTEGER","explorer_common_hash":"RECORD_SAME_64_HEX_HASH","outbound_core31_peers":"RECORD_INTEGER_AT_LEAST_3","verifychain_level":4,"verifychain_passed":"RECORD_BOOLEAN","payout_configured":"RECORD_BOOLEAN","payout_preserved":"RECORD_BOOLEAN","pool_stratum_result":"RECORD_passed","app_ui_privacy_passed":"RECORD_BOOLEAN","telemetry_disabled":"RECORD_BOOLEAN","app_rollback_rejected":"RECORD_BOOLEAN","os_rollback_rejected":"RECORD_BOOLEAN"}},h,indent=2); h.write("\n") PY chmod 0644 "$evidence_tmp" mv -f "$tmp" "$compose"; mv -f "$evidence_tmp" "$evidence_output" diff --git a/tests/test_axebc2_dev_finalizer.py b/tests/test_axebc2_dev_finalizer.py index 6526123..f14c33d 100644 --- a/tests/test_axebc2_dev_finalizer.py +++ b/tests/test_axebc2_dev_finalizer.py @@ -11,7 +11,7 @@ COMPOSE = ROOT / "willitmod-dev-bc2/docker-compose.yml" APP_DIGEST = "sha256:" + "a" * 64 CORE_DIGEST = "sha256:" + "b" * 64 -CORE_TAG = "31.1.0-rc.3c2cafcab19e" +CORE_TAG = "31.1.0-rc.cdf44542dde2" class AxeBC2DevFinalizerTests(unittest.TestCase): def setUp(self): @@ -30,7 +30,7 @@ def setUp(self): if [ "$1 $2" = 'buildx imagetools' ]; then case "$4" in ghcr.io/willitmod/axebc2-app-umbrel-dev:0.1.10-candidate.6e4ef58218e8) printf 'Digest: %s\\n' "$APP_DIGEST" ;; - ghcr.io/willitmod/bitcoinii-core:31.1.0-rc.3c2cafcab19e) printf 'Digest: %s\\n' "$CORE_DIGEST" ;; + ghcr.io/willitmod/bitcoinii-core:31.1.0-rc.cdf44542dde2) printf 'Digest: %s\\n' "$CORE_DIGEST" ;; *) exit 2 ;; esac elif [ "$1 $2" = 'manifest inspect' ]; then @@ -55,8 +55,8 @@ def test_anonymous_candidate_checks_finalize_and_emit_evidence(self): self.assertEqual(compose.count(core_ref),2) evidence=json.loads((self.root/"willitmod-dev-bc2/DEV-ACCEPTANCE-EVIDENCE.json").read_text(encoding="utf-8")) self.assertEqual(evidence["source_revision"],"6e4ef58218e8cd5a4d1113196f9872a7f501f52e") - self.assertEqual(evidence["core_source_revision"],"3c2cafcab19efde33c1e476a982c3389957dacb2") - self.assertEqual(evidence["core_candidate_run"],33674007419) + self.assertEqual(evidence["core_source_revision"],"cdf44542dde255648008249d187fafc15f3a2f09") + self.assertEqual(evidence["core_candidate_run"],33675068951) self.assertEqual(evidence["app_digest"],APP_DIGEST); self.assertEqual(evidence["core_digest"],CORE_DIGEST) calls=self.log.read_text(encoding="utf-8") self.assertEqual(calls.count("--platform linux/amd64"),2); self.assertEqual(calls.count("--platform linux/arm64"),2) diff --git a/willitmod-dev-bc2/CORE31-DEV-RELEASE.md b/willitmod-dev-bc2/CORE31-DEV-RELEASE.md index 34e1033..1311e38 100644 --- a/willitmod-dev-bc2/CORE31-DEV-RELEASE.md +++ b/willitmod-dev-bc2/CORE31-DEV-RELEASE.md @@ -35,9 +35,9 @@ Run `scripts/finalize-axebc2-0.1.10-dev.sh` with the exact application index digest, exact Core candidate tag and exact Core index digest. The application candidate is fixed to `0.1.10-candidate.6e4ef58218e8` from source revision `6e4ef58218e8cd5a4d1113196f9872a7f501f52e`. The Core candidate is fixed to -`31.1.0-rc.3c2cafcab19e` from source revision -`3c2cafcab19efde33c1e476a982c3389957dacb2`, candidate workflow run -`33674007419`. Before editing Compose, the +`31.1.0-rc.cdf44542dde2` from source revision +`cdf44542dde255648008249d187fafc15f3a2f09`, candidate workflow run +`33675068951`. Before editing Compose, the finalizer anonymously verifies candidate resolution, amd64 and arm64 manifests and pulls. It atomically replaces every sentinel and emits both exact source revisions in the evidence JSON template, which must be completed only after diff --git a/willitmod-dev-bc2/docker-compose.yml b/willitmod-dev-bc2/docker-compose.yml index 6ce665b..a002422 100644 --- a/willitmod-dev-bc2/docker-compose.yml +++ b/willitmod-dev-bc2/docker-compose.yml @@ -54,7 +54,7 @@ services: - exec /bin/sh /opt/axebc2/init.sh btc2d: - image: ghcr.io/willitmod/bitcoinii-core:31.1.0-rc.3c2cafcab19e@sha256:CORE31_CANDIDATE_DIGEST_REQUIRED + image: ghcr.io/willitmod/bitcoinii-core:31.1.0-rc.cdf44542dde2@sha256:CORE31_CANDIDATE_DIGEST_REQUIRED user: "1000:1000" restart: unless-stopped stop_grace_period: 15m30s @@ -136,7 +136,7 @@ services: STATIC_DIR: "/app/static" APP_CHANNEL: "ALPHA" APP_VERSION_SUFFIX: "-dev" - BTC2D_IMAGE: "ghcr.io/willitmod/bitcoinii-core:31.1.0-rc.3c2cafcab19e@sha256:CORE31_CANDIDATE_DIGEST_REQUIRED" + BTC2D_IMAGE: "ghcr.io/willitmod/bitcoinii-core:31.1.0-rc.cdf44542dde2@sha256:CORE31_CANDIDATE_DIGEST_REQUIRED" CKPOOL_IMAGE: "ghcr.io/willitmod/docker-ckpool-solo:590fb2a@sha256:8a9a7f10c8138d0f55533132ee7710a06715a42a49f75efb39be3350ada4fa6e" SUPPORT_CHECKIN_ENABLED: "false" BTC2_RPC_HOST: "btc2d" From 90a07ca2dc8c66f710e4b56e7ff7383ea56b3b17 Mon Sep 17 00:00:00 2001 From: Johnny Murray Date: Wed, 2 Sep 2026 20:53:12 +0100 Subject: [PATCH 11/18] Resolve DEV candidates anonymously via GHCR --- scripts/finalize-axebc2-0.1.10-dev.sh | 15 +++++++++--- tests/test_axebc2_dev_finalizer.py | 33 +++++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/scripts/finalize-axebc2-0.1.10-dev.sh b/scripts/finalize-axebc2-0.1.10-dev.sh index adc3402..4129bdc 100755 --- a/scripts/finalize-axebc2-0.1.10-dev.sh +++ b/scripts/finalize-axebc2-0.1.10-dev.sh @@ -10,6 +10,8 @@ repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" compose="$repo_root/willitmod-dev-bc2/docker-compose.yml" evidence_output="${4:-$repo_root/willitmod-dev-bc2/DEV-ACCEPTANCE-EVIDENCE.json}" docker_bin="${DOCKER_BIN:-docker}" +curl_bin="${CURL_BIN:-curl}" +jq_bin="${JQ_BIN:-jq}" app_tag="ghcr.io/willitmod/axebc2-app-umbrel-dev:0.1.10-candidate.6e4ef58218e8" app_revision="6e4ef58218e8cd5a4d1113196f9872a7f501f52e" core_revision="cdf44542dde255648008249d187fafc15f3a2f09" @@ -19,6 +21,8 @@ fail() { echo "ERROR: $*" >&2; exit 1; } [[ "$core_digest" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "Core digest is not an exact sha256 digest" [[ "$core_candidate_tag" == "31.1.0-rc.cdf44542dde2" ]] || fail "Core tag must be 31.1.0-rc.cdf44542dde2" command -v "$docker_bin" >/dev/null 2>&1 || fail "Docker is required for registry verification" +command -v "$curl_bin" >/dev/null 2>&1 || fail "curl is required for anonymous registry verification" +command -v "$jq_bin" >/dev/null 2>&1 || fail "jq is required for anonymous registry verification" anon_config="$(mktemp -d "${TMPDIR:-/tmp}/axebc2-anonymous-docker.XXXXXX")" cleanup() { rm -rf -- "$anon_config"; } @@ -26,10 +30,15 @@ trap cleanup EXIT printf '{"auths":{}}\n' >"$anon_config/config.json" resolve_tag() { - local ref="$1" expected="$2" output resolved + local ref="$1" expected="$2" path repository tag token headers resolved [[ "$ref" == "$app_tag" || "$ref" == "$core_tag" ]] || fail "not an approved candidate tag: $ref" - output="$("$docker_bin" --config "$anon_config" buildx imagetools inspect "$ref")" || fail "anonymous resolution failed: $ref" - resolved="$(printf '%s\n' "$output" | awk '$1 == "Digest:" {print $2; exit}')" + path="${ref#ghcr.io/}"; repository="${path%:*}"; tag="${path##*:}" + token="$("$curl_bin" -fsSL "https://ghcr.io/token?service=ghcr.io&scope=repository:${repository}:pull" | "$jq_bin" -er '.token')" || fail "anonymous token request failed: $ref" + headers="$("$curl_bin" -fsSI -H "Authorization: Bearer $token" \ + -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' \ + "https://ghcr.io/v2/${repository}/manifests/${tag}")" || fail "anonymous manifest HEAD failed: $ref" + resolved="$(printf '%s\n' "$headers" | awk 'BEGIN{IGNORECASE=1} /^Docker-Content-Digest:/ {gsub("\\r","",$2); print $2}' | tail -n 1)" + [[ "$resolved" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "$ref returned a missing or malformed Docker-Content-Digest" [[ "$resolved" == "$expected" ]] || fail "$ref resolves to ${resolved:-nothing}, expected $expected" } verify_index() { diff --git a/tests/test_axebc2_dev_finalizer.py b/tests/test_axebc2_dev_finalizer.py index f14c33d..911a6da 100644 --- a/tests/test_axebc2_dev_finalizer.py +++ b/tests/test_axebc2_dev_finalizer.py @@ -40,11 +40,31 @@ def setUp(self): fi """, encoding="utf-8") self.fake.chmod(0o755) + self.curl_log = self.root / "curl.log" + self.fake_curl = self.root / "curl" + self.fake_curl.write_text("""#!/bin/sh +set -eu +printf '%s\\n' "$*" >>"$FAKE_CURL_LOG" +for arg in "$@"; do url="$arg"; done +case "$url" in + *'/token?'*) printf '%s\\n' '{"token":"anonymous-test-token"}' ;; + *) + case "${CURL_DIGEST_MODE:-correct}" in + correct) case "$url" in *axebc2-app-umbrel-dev*) digest="$APP_DIGEST";; *) digest="$CORE_DIGEST";; esac ;; + wrong) digest="sha256:$(printf '%064d' 0)" ;; + missing) printf 'HTTP/2 200\\r\\n\\r\\n'; exit 0 ;; + malformed) digest='sha256:not-a-digest' ;; + esac + printf 'HTTP/2 200\\r\\nDocker-Content-Digest: %s\\r\\n\\r\\n' "$digest" + ;; +esac +""", encoding="utf-8") + self.fake_curl.chmod(0o755) def tearDown(self): self.temp.cleanup() - def run_it(self, core_tag=CORE_TAG): - env=os.environ.copy(); env.update({"DOCKER_BIN":str(self.fake),"FAKE_DOCKER_LOG":str(self.log),"APP_DIGEST":APP_DIGEST,"CORE_DIGEST":CORE_DIGEST}) + def run_it(self, core_tag=CORE_TAG, curl_mode="correct"): + env=os.environ.copy(); env.update({"DOCKER_BIN":str(self.fake),"CURL_BIN":str(self.fake_curl),"FAKE_DOCKER_LOG":str(self.log),"FAKE_CURL_LOG":str(self.curl_log),"CURL_DIGEST_MODE":curl_mode,"APP_DIGEST":APP_DIGEST,"CORE_DIGEST":CORE_DIGEST}) return subprocess.run([str(self.root/"scripts"/SCRIPT.name),APP_DIGEST,core_tag,CORE_DIGEST],env=env,text=True,capture_output=True,check=False) def test_anonymous_candidate_checks_finalize_and_emit_evidence(self): @@ -60,6 +80,15 @@ def test_anonymous_candidate_checks_finalize_and_emit_evidence(self): self.assertEqual(evidence["app_digest"],APP_DIGEST); self.assertEqual(evidence["core_digest"],CORE_DIGEST) calls=self.log.read_text(encoding="utf-8") self.assertEqual(calls.count("--platform linux/amd64"),2); self.assertEqual(calls.count("--platform linux/arm64"),2) + self.assertNotIn("buildx", calls) + self.assertTrue(all("--config" in line for line in calls.splitlines())) + + def test_bad_registry_digest_headers_fail_without_docker_or_mutation(self): + for mode in ("wrong", "missing", "malformed"): + if self.log.exists(): self.log.unlink() + result=self.run_it(curl_mode=mode); self.assertNotEqual(result.returncode,0) + self.assertFalse(self.log.exists()) + self.assertEqual((self.root/"willitmod-dev-bc2/docker-compose.yml").read_bytes(),self.original) def test_wrong_core_revision_tag_fails_before_registry_and_mutation(self): result=self.run_it("31.1.0-rc.000000000000"); self.assertNotEqual(result.returncode,0) From 3f1d426bfe1fb1da29104745ef360c88f1663fb5 Mon Sep 17 00:00:00 2001 From: Johnny Murray Date: Wed, 2 Sep 2026 20:54:13 +0100 Subject: [PATCH 12/18] Parse lowercase GHCR digest headers portably --- scripts/finalize-axebc2-0.1.10-dev.sh | 2 +- tests/test_axebc2_dev_finalizer.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/finalize-axebc2-0.1.10-dev.sh b/scripts/finalize-axebc2-0.1.10-dev.sh index 4129bdc..9a6e0c2 100755 --- a/scripts/finalize-axebc2-0.1.10-dev.sh +++ b/scripts/finalize-axebc2-0.1.10-dev.sh @@ -37,7 +37,7 @@ resolve_tag() { headers="$("$curl_bin" -fsSI -H "Authorization: Bearer $token" \ -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' \ "https://ghcr.io/v2/${repository}/manifests/${tag}")" || fail "anonymous manifest HEAD failed: $ref" - resolved="$(printf '%s\n' "$headers" | awk 'BEGIN{IGNORECASE=1} /^Docker-Content-Digest:/ {gsub("\\r","",$2); print $2}' | tail -n 1)" + resolved="$(printf '%s\n' "$headers" | awk 'tolower($0) ~ /^docker-content-digest:/ {sub(/^[^:]*:[[:space:]]*/, ""); sub(/\r$/, ""); print}' | tail -n 1)" [[ "$resolved" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "$ref returned a missing or malformed Docker-Content-Digest" [[ "$resolved" == "$expected" ]] || fail "$ref resolves to ${resolved:-nothing}, expected $expected" } diff --git a/tests/test_axebc2_dev_finalizer.py b/tests/test_axebc2_dev_finalizer.py index 911a6da..60eacb1 100644 --- a/tests/test_axebc2_dev_finalizer.py +++ b/tests/test_axebc2_dev_finalizer.py @@ -55,7 +55,7 @@ def setUp(self): missing) printf 'HTTP/2 200\\r\\n\\r\\n'; exit 0 ;; malformed) digest='sha256:not-a-digest' ;; esac - printf 'HTTP/2 200\\r\\nDocker-Content-Digest: %s\\r\\n\\r\\n' "$digest" + printf 'HTTP/2 200\\r\\ndocker-content-digest: %s\\r\\n\\r\\n' "$digest" ;; esac """, encoding="utf-8") From ccdaee7aaf0ebdb71f9b4dd9100da2b95df7fc71 Mon Sep 17 00:00:00 2001 From: Johnny Murray Date: Wed, 2 Sep 2026 20:58:06 +0100 Subject: [PATCH 13/18] Bind DEV checks to Docker context and OS 0.7.12 --- scripts/finalize-axebc2-0.1.10-dev.sh | 15 +++++++++++---- scripts/validate-axebc2-core31-dev.py | 2 +- tests/test_axebc2_core31_init.py | 6 +++--- tests/test_axebc2_dev_finalizer.py | 12 +++++++++--- tests/test_axebc2_platform_integration.py | 2 +- willitmod-dev-bc2/CORE31-DEV-RELEASE.md | 2 +- willitmod-dev-bc2/data/init/init.sh | 2 +- willitmod-dev-bc2/umbrel-app.yml | 4 ++-- 8 files changed, 29 insertions(+), 16 deletions(-) diff --git a/scripts/finalize-axebc2-0.1.10-dev.sh b/scripts/finalize-axebc2-0.1.10-dev.sh index 9a6e0c2..61e79ee 100755 --- a/scripts/finalize-axebc2-0.1.10-dev.sh +++ b/scripts/finalize-axebc2-0.1.10-dev.sh @@ -23,6 +23,13 @@ fail() { echo "ERROR: $*" >&2; exit 1; } command -v "$docker_bin" >/dev/null 2>&1 || fail "Docker is required for registry verification" command -v "$curl_bin" >/dev/null 2>&1 || fail "curl is required for anonymous registry verification" command -v "$jq_bin" >/dev/null 2>&1 || fail "jq is required for anonymous registry verification" +docker_host="${DOCKER_HOST:-}" +if [[ -z "$docker_host" ]]; then + active_context="$("$docker_bin" context show)" || fail "cannot determine active Docker context" + [[ -n "$active_context" ]] || fail "active Docker context is empty" + docker_host="$("$docker_bin" context inspect "$active_context" --format '{{.Endpoints.docker.Host}}')" || fail "cannot resolve active Docker daemon endpoint" +fi +[[ "$docker_host" =~ ^(unix|tcp|ssh|npipe)://[^[:space:]]+$ ]] || fail "Docker daemon endpoint is missing or malformed" anon_config="$(mktemp -d "${TMPDIR:-/tmp}/axebc2-anonymous-docker.XXXXXX")" cleanup() { rm -rf -- "$anon_config"; } @@ -43,15 +50,15 @@ resolve_tag() { } verify_index() { local ref="$1" digest="$2" manifest - manifest="$("$docker_bin" --config "$anon_config" manifest inspect "$ref@$digest")" || fail "anonymous inspection failed: $ref@$digest" + manifest="$("$docker_bin" --host "$docker_host" --config "$anon_config" manifest inspect "$ref@$digest")" || fail "anonymous inspection failed: $ref@$digest" python3 -c ' import json,sys d=json.load(sys.stdin); p={(m.get("platform",{}).get("os"),m.get("platform",{}).get("architecture")) for m in d.get("manifests",[])} missing={("linux","amd64"),("linux","arm64")}-p if missing: raise SystemExit("missing required platforms: "+str(sorted(missing))) ' <<<"$manifest" || fail "$ref@$digest is not an amd64+arm64 index" - "$docker_bin" --config "$anon_config" pull --platform linux/amd64 "$ref@$digest" >/dev/null || fail "anonymous amd64 pull failed" - "$docker_bin" --config "$anon_config" pull --platform linux/arm64 "$ref@$digest" >/dev/null || fail "anonymous arm64 pull failed" + "$docker_bin" --host "$docker_host" --config "$anon_config" pull --platform linux/amd64 "$ref@$digest" >/dev/null || fail "anonymous amd64 pull failed" + "$docker_bin" --host "$docker_host" --config "$anon_config" pull --platform linux/arm64 "$ref@$digest" >/dev/null || fail "anonymous arm64 pull failed" } resolve_tag "$app_tag" "$app_digest"; resolve_tag "$core_tag" "$core_digest" verify_index "$app_tag" "$app_digest"; verify_index "$core_tag" "$core_digest" @@ -73,7 +80,7 @@ python3 - "$evidence_tmp" "$app_tag" "$app_digest" "$app_revision" "$core_tag" " import json,sys path,app_image,app_digest,revision,core_image,core_digest,core_revision=sys.argv[1:] with open(path,"w",encoding="utf-8") as h: - json.dump({"schema":1,"result":"RECORD_passed_AFTER_LIVE_DEV_ACCEPTANCE","app_image":app_image,"app_digest":app_digest,"core_image":core_image,"core_digest":core_digest,"app_version":"0.1.10-dev","source_revision":revision,"core_source_revision":core_revision,"core_candidate_run":33675068951,"tested_on":"RECORD_TEST_NODE","tested_at":"RECORD_ISO_8601_TIMESTAMP","acceptance":{"observed_at":"RECORD_ISO_8601_TIMESTAMP","core_version":"RECORD_INTEGER_VERSION","migration_required_marker_absent":"RECORD_BOOLEAN","migration_complete_marker_valid":"RECORD_BOOLEAN","checkpoint_height":57752,"checkpoint_hash":"000000000000000013ceffe797280c57f75a5b9f1d9e70c3503584058c322576","chainwork":"RECORD_64_HEX_CHAINWORK","ibd":False,"verification_progress":"RECORD_NUMBER","blocks":"RECORD_INTEGER","headers":"RECORD_SAME_INTEGER","best_block_hash":"RECORD_64_HEX_HASH","explorer_common_height":"RECORD_SAME_INTEGER","explorer_common_hash":"RECORD_SAME_64_HEX_HASH","outbound_core31_peers":"RECORD_INTEGER_AT_LEAST_3","verifychain_level":4,"verifychain_passed":"RECORD_BOOLEAN","payout_configured":"RECORD_BOOLEAN","payout_preserved":"RECORD_BOOLEAN","pool_stratum_result":"RECORD_passed","app_ui_privacy_passed":"RECORD_BOOLEAN","telemetry_disabled":"RECORD_BOOLEAN","app_rollback_rejected":"RECORD_BOOLEAN","os_rollback_rejected":"RECORD_BOOLEAN"}},h,indent=2); h.write("\n") + json.dump({"schema":1,"result":"RECORD_passed_AFTER_LIVE_DEV_ACCEPTANCE","app_image":app_image,"app_digest":app_digest,"core_image":core_image,"core_digest":core_digest,"app_version":"0.1.10-dev","source_revision":revision,"core_source_revision":core_revision,"core_candidate_run":33675068951,"tested_os_version":"v0.7.12-dev","tested_os_bundle_sha256":"RECORD_64_HEX_OS_BUNDLE_SHA256","tested_on":"RECORD_TEST_NODE","tested_at":"RECORD_ISO_8601_TIMESTAMP","acceptance":{"observed_at":"RECORD_ISO_8601_TIMESTAMP","chain":"main","core_version":"RECORD_INTEGER_VERSION","migration_required_marker_absent":"RECORD_BOOLEAN","migration_started_marker_valid":"RECORD_BOOLEAN","migration_complete_marker_valid":"RECORD_BOOLEAN","checkpoint_height":57752,"checkpoint_hash":"000000000000000013ceffe797280c57f75a5b9f1d9e70c3503584058c322576","chainwork":"RECORD_64_HEX_CHAINWORK","ibd":False,"verification_progress":"RECORD_NUMBER","blocks":"RECORD_INTEGER","headers":"RECORD_SAME_INTEGER","best_block_hash":"RECORD_64_HEX_HASH","explorer_common_height":"RECORD_SAME_INTEGER","explorer_common_hash":"RECORD_SAME_64_HEX_HASH","outbound_core31_peers":"RECORD_INTEGER_AT_LEAST_3","competing_valid_tips":0,"verifychain_level":4,"verifychain_passed":"RECORD_BOOLEAN","payout_configured":"RECORD_BOOLEAN","payout_preserved":"RECORD_BOOLEAN","pool_stratum_result":"RECORD_passed","app_ui_privacy_passed":"RECORD_BOOLEAN","telemetry_disabled":"RECORD_BOOLEAN","p2p_port_unpublished":"RECORD_BOOLEAN","natpmp_disabled":"RECORD_BOOLEAN","post_completion_restart_passed":"RECORD_BOOLEAN","reindex_not_repeated":"RECORD_BOOLEAN","app_rollback_rejected":"RECORD_BOOLEAN","os_rollback_rejected":"RECORD_BOOLEAN"}},h,indent=2); h.write("\n") PY chmod 0644 "$evidence_tmp" mv -f "$tmp" "$compose"; mv -f "$evidence_tmp" "$evidence_output" diff --git a/scripts/validate-axebc2-core31-dev.py b/scripts/validate-axebc2-core31-dev.py index a0616dd..106a482 100644 --- a/scripts/validate-axebc2-core31-dev.py +++ b/scripts/validate-axebc2-core31-dev.py @@ -34,7 +34,7 @@ def require(condition, message): node_config = (APP / "data/templates/bitcoinII.conf.template").read_text(encoding="utf-8") require('version: "0.1.10-dev"' in manifest, "manifest must be 0.1.10-dev") -require("Requires 5tratumOS 0.7.11" in manifest, "OS prerequisite must be disclosed") +require("Requires 5tratumOS 0.7.12" in manifest, "OS prerequisite must be disclosed") require('"2345:3333/tcp"' in compose, "Stratum host port 2345 must be retained") require("SUPPORT_CHECKIN_ENABLED: \"false\"" in compose, "telemetry must default off") require("create_host_path: false" in compose, "build metadata bind must fail closed") diff --git a/tests/test_axebc2_core31_init.py b/tests/test_axebc2_core31_init.py index d8fe988..c3b8ae7 100644 --- a/tests/test_axebc2_core31_init.py +++ b/tests/test_axebc2_core31_init.py @@ -25,7 +25,7 @@ def setUp(self): def tearDown(self): shutil.rmtree(self.tmp) - def run_init(self, tag="0.7.11", expect=0, jwt_secret=None): + def run_init(self, tag="0.7.12", expect=0, jwt_secret=None): self.build.write_text(json.dumps({"tag": tag}), encoding="utf-8") env = os.environ.copy() env.pop("JWT_SECRET", None) @@ -81,7 +81,7 @@ def test_policy_and_reindex_requirement_exist_without_rpc(self): (self.data / ".5tratumos-rollback-policy.json").read_text(encoding="utf-8") ) self.assertEqual(policy["minimum_base_version"], "0.1.10") - self.assertEqual(policy["minimum_5tratumos_version"], "0.7.11") + self.assertEqual(policy["minimum_5tratumos_version"], "0.7.12") marker = json.loads( (self.data / "node/.core31-full-reindex-required.json").read_text( encoding="utf-8" @@ -182,7 +182,7 @@ def test_dependency_install_failure_precedes_persistent_mutation(self): apk = tool_dir / "apk" apk.write_text("#!/bin/sh\nexit 42\n", encoding="utf-8") apk.chmod(0o755) - self.build.write_text(json.dumps({"tag": "0.7.11"}), encoding="utf-8") + self.build.write_text(json.dumps({"tag": "0.7.12"}), encoding="utf-8") sentinel = self.data / "unchanged" sentinel.write_text("original", encoding="utf-8") env = os.environ.copy() diff --git a/tests/test_axebc2_dev_finalizer.py b/tests/test_axebc2_dev_finalizer.py index 60eacb1..2c11e09 100644 --- a/tests/test_axebc2_dev_finalizer.py +++ b/tests/test_axebc2_dev_finalizer.py @@ -26,7 +26,7 @@ def setUp(self): self.fake.write_text("""#!/bin/sh set -eu printf '%s\\n' "$*" >>"$FAKE_DOCKER_LOG" -config="$2"; [ "$1" = --config ]; [ "$(cat "$config/config.json")" = '{"auths":{}}' ]; shift 2 +host="$2"; config="$4"; [ "$1" = --host ]; [ "$3" = --config ]; [ -n "$host" ]; [ "$(cat "$config/config.json")" = '{"auths":{}}' ]; shift 4 if [ "$1 $2" = 'buildx imagetools' ]; then case "$4" in ghcr.io/willitmod/axebc2-app-umbrel-dev:0.1.10-candidate.6e4ef58218e8) printf 'Digest: %s\\n' "$APP_DIGEST" ;; @@ -64,7 +64,7 @@ def setUp(self): def tearDown(self): self.temp.cleanup() def run_it(self, core_tag=CORE_TAG, curl_mode="correct"): - env=os.environ.copy(); env.update({"DOCKER_BIN":str(self.fake),"CURL_BIN":str(self.fake_curl),"FAKE_DOCKER_LOG":str(self.log),"FAKE_CURL_LOG":str(self.curl_log),"CURL_DIGEST_MODE":curl_mode,"APP_DIGEST":APP_DIGEST,"CORE_DIGEST":CORE_DIGEST}) + env=os.environ.copy(); env.update({"DOCKER_BIN":str(self.fake),"DOCKER_HOST":"unix:///tmp/test-colima.sock","CURL_BIN":str(self.fake_curl),"FAKE_DOCKER_LOG":str(self.log),"FAKE_CURL_LOG":str(self.curl_log),"CURL_DIGEST_MODE":curl_mode,"APP_DIGEST":APP_DIGEST,"CORE_DIGEST":CORE_DIGEST}) return subprocess.run([str(self.root/"scripts"/SCRIPT.name),APP_DIGEST,core_tag,CORE_DIGEST],env=env,text=True,capture_output=True,check=False) def test_anonymous_candidate_checks_finalize_and_emit_evidence(self): @@ -81,7 +81,13 @@ def test_anonymous_candidate_checks_finalize_and_emit_evidence(self): calls=self.log.read_text(encoding="utf-8") self.assertEqual(calls.count("--platform linux/amd64"),2); self.assertEqual(calls.count("--platform linux/arm64"),2) self.assertNotIn("buildx", calls) - self.assertTrue(all("--config" in line for line in calls.splitlines())) + self.assertTrue(all("--host unix:///tmp/test-colima.sock --config" in line for line in calls.splitlines())) + + def test_bad_explicit_docker_host_fails_before_registry_or_mutation(self): + env=os.environ.copy(); env.update({"DOCKER_BIN":str(self.fake),"DOCKER_HOST":"not-an-endpoint","CURL_BIN":str(self.fake_curl),"FAKE_DOCKER_LOG":str(self.log),"FAKE_CURL_LOG":str(self.curl_log),"APP_DIGEST":APP_DIGEST,"CORE_DIGEST":CORE_DIGEST}) + result=subprocess.run([str(self.root/"scripts"/SCRIPT.name),APP_DIGEST,CORE_TAG,CORE_DIGEST],env=env,text=True,capture_output=True,check=False) + self.assertNotEqual(result.returncode,0); self.assertFalse(self.log.exists()); self.assertFalse(self.curl_log.exists()) + self.assertEqual((self.root/"willitmod-dev-bc2/docker-compose.yml").read_bytes(),self.original) def test_bad_registry_digest_headers_fail_without_docker_or_mutation(self): for mode in ("wrong", "missing", "malformed"): diff --git a/tests/test_axebc2_platform_integration.py b/tests/test_axebc2_platform_integration.py index 4a1d067..a359215 100644 --- a/tests/test_axebc2_platform_integration.py +++ b/tests/test_axebc2_platform_integration.py @@ -96,7 +96,7 @@ def test_real_dev_store_id_maps_to_axebc2_and_policy_is_accepted(self): data.mkdir(parents=True) build = temp / "etc/5tratumos/build.json" build.parent.mkdir(parents=True) - build.write_text(json.dumps({"tag": "0.7.11"}), encoding="utf-8") + build.write_text(json.dumps({"tag": "0.7.12"}), encoding="utf-8") env = os.environ.copy() env.update( { diff --git a/willitmod-dev-bc2/CORE31-DEV-RELEASE.md b/willitmod-dev-bc2/CORE31-DEV-RELEASE.md index 1311e38..e594179 100644 --- a/willitmod-dev-bc2/CORE31-DEV-RELEASE.md +++ b/willitmod-dev-bc2/CORE31-DEV-RELEASE.md @@ -28,7 +28,7 @@ two identical immutable Core sha256 pins. A partial or mixed state is rejected. They must be replaced with the exact verified multi-architecture candidate digests. After substitution, the merged platform Compose must pass validation, all images must pull anonymously by digest, init must complete successfully on -5tratumOS 0.7.11+, and the resulting installation must be tested on DEV before +5tratumOS 0.7.12+, and the resulting installation must be tested on DEV before any production promotion. Run `scripts/finalize-axebc2-0.1.10-dev.sh` with the exact application index diff --git a/willitmod-dev-bc2/data/init/init.sh b/willitmod-dev-bc2/data/init/init.sh index ec42933..4e9cc5a 100644 --- a/willitmod-dev-bc2/data/init/init.sh +++ b/willitmod-dev-bc2/data/init/init.sh @@ -9,7 +9,7 @@ policy_file="${data_dir}/.5tratumos-rollback-policy.json" node_dir="${data_dir}/node" required_marker="${node_dir}/.core31-full-reindex-required.json" complete_marker="${node_dir}/.core31-full-reindex-complete.json" -minimum_os="0.7.11" +minimum_os="0.7.12" minimum_app="0.1.10" migration="bitcoinii-shockwave-core31-full-reindex" diff --git a/willitmod-dev-bc2/umbrel-app.yml b/willitmod-dev-bc2/umbrel-app.yml index 8ca2e32..24fccc5 100644 --- a/willitmod-dev-bc2/umbrel-app.yml +++ b/willitmod-dev-bc2/umbrel-app.yml @@ -25,7 +25,7 @@ description: >- Notes: - Set your payout address in the app Settings tab. - - Requires 5tratumOS 0.7.11 or newer. + - Requires 5tratumOS 0.7.12 or newer. - The node makes outbound peer connections; this app does not publish a public P2P port or request NAT-PMP mappings. - Initial sync can take a long time and a lot of bandwidth/storage (the node downloads and validates the chain). - Solo mining is extremely unlikely to find blocks without significant hashrate. @@ -48,7 +48,7 @@ defaultUsername: "" defaultPassword: "" releaseNotes: >- Upgrades BitcoinII Core to 31.1 for the ShockWave consensus change. Requires - 5tratumOS 0.7.11 or newer. Back up your app data before upgrading. Existing + 5tratumOS 0.7.12 or newer. Back up your app data before upgrading. Existing nodes must perform a full reindex of their stored blockchain data; mining and sync data will be unavailable until validation completes. Stratum remains on TCP port 2345. Support telemetry is disabled by default. The node uses outbound From 077a6463b41317e314599bd6d92aa734c07d3df0 Mon Sep 17 00:00:00 2001 From: Johnny Murray Date: Wed, 2 Sep 2026 20:59:48 +0100 Subject: [PATCH 14/18] Make DEV finalizer tests phase independent --- tests/test_axebc2_dev_finalizer.py | 8 +++++++- tests/test_axebc2_release_state.py | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/test_axebc2_dev_finalizer.py b/tests/test_axebc2_dev_finalizer.py index 2c11e09..54e2d56 100644 --- a/tests/test_axebc2_dev_finalizer.py +++ b/tests/test_axebc2_dev_finalizer.py @@ -1,5 +1,6 @@ import json import os +import re from pathlib import Path import shutil import subprocess @@ -19,7 +20,12 @@ def setUp(self): self.root = Path(self.temp.name) (self.root / "scripts").mkdir(); (self.root / "willitmod-dev-bc2").mkdir() shutil.copy2(SCRIPT, self.root / "scripts" / SCRIPT.name) - shutil.copy2(COMPOSE, self.root / "willitmod-dev-bc2/docker-compose.yml") + fixture = COMPOSE.read_text(encoding="utf-8") + fixture = re.sub(r"(ghcr\.io/willitmod/axebc2-app-umbrel-dev:0\.1\.10-candidate\.6e4ef58218e8@sha256:)[0-9a-f]{64}", r"\1APP_CANDIDATE_DIGEST_REQUIRED", fixture) + fixture = re.sub(r"(ghcr\.io/willitmod/bitcoinii-core:31\.1\.0-rc\.cdf44542dde2@sha256:)[0-9a-f]{64}", r"\1CORE31_CANDIDATE_DIGEST_REQUIRED", fixture) + (self.root / "willitmod-dev-bc2/docker-compose.yml").write_text(fixture, encoding="utf-8") + self.assertEqual(fixture.count("APP_CANDIDATE_DIGEST_REQUIRED"), 1) + self.assertEqual(fixture.count("CORE31_CANDIDATE_DIGEST_REQUIRED"), 2) self.original = (self.root / "willitmod-dev-bc2/docker-compose.yml").read_bytes() self.log = self.root / "docker.log" self.fake = self.root / "docker" diff --git a/tests/test_axebc2_release_state.py b/tests/test_axebc2_release_state.py index ad2e43d..7c6aedb 100644 --- a/tests/test_axebc2_release_state.py +++ b/tests/test_axebc2_release_state.py @@ -15,3 +15,9 @@ def test_only_complete_immutable_finalization_is_accepted(self): validate(text,"finalized") with self.assertRaises(ValueError): validate(text.replace(c,"sha256:"+"d"*64,1),"finalized") with self.assertRaises(ValueError): validate(text+"\n_DIGEST_REQUIRED","finalized") + def test_lifecycle_matrix_rejects_cross_phase_validation(self): + pre=f"{APP_TAG}@sha256:APP_CANDIDATE_DIGEST_REQUIRED\n{CORE_TAG}@sha256:CORE31_CANDIDATE_DIGEST_REQUIRED\n{CORE_TAG}@sha256:CORE31_CANDIDATE_DIGEST_REQUIRED" + final=f"{APP_TAG}@sha256:{'a'*64}\n{CORE_TAG}@sha256:{'c'*64}\n{CORE_TAG}@sha256:{'c'*64}" + validate(pre,"prefinalization"); validate(final,"finalized") + with self.assertRaises(ValueError): validate(pre,"finalized") + with self.assertRaises(ValueError): validate(final,"prefinalization") From d137ebfa8f87a8ddc57698bec2e1450a2a92f5a1 Mon Sep 17 00:00:00 2001 From: Johnny Murray Date: Wed, 2 Sep 2026 21:00:20 +0100 Subject: [PATCH 15/18] Pin AxeBC2 DEV release candidates --- .../DEV-ACCEPTANCE-EVIDENCE.json | 49 +++++++++++++++++++ willitmod-dev-bc2/docker-compose.yml | 6 +-- 2 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 willitmod-dev-bc2/DEV-ACCEPTANCE-EVIDENCE.json diff --git a/willitmod-dev-bc2/DEV-ACCEPTANCE-EVIDENCE.json b/willitmod-dev-bc2/DEV-ACCEPTANCE-EVIDENCE.json new file mode 100644 index 0000000..2ea9152 --- /dev/null +++ b/willitmod-dev-bc2/DEV-ACCEPTANCE-EVIDENCE.json @@ -0,0 +1,49 @@ +{ + "schema": 1, + "result": "RECORD_passed_AFTER_LIVE_DEV_ACCEPTANCE", + "app_image": "ghcr.io/willitmod/axebc2-app-umbrel-dev:0.1.10-candidate.6e4ef58218e8", + "app_digest": "sha256:b7ba2df2f48389d145ad18a927b099f32b5aa7708a0ea617a1b04e25c8e7f961", + "core_image": "ghcr.io/willitmod/bitcoinii-core:31.1.0-rc.cdf44542dde2", + "core_digest": "sha256:8875917ece57668fe9925d40a256ce8d429a3071511bb555d4ace1fa4370afc6", + "app_version": "0.1.10-dev", + "source_revision": "6e4ef58218e8cd5a4d1113196f9872a7f501f52e", + "core_source_revision": "cdf44542dde255648008249d187fafc15f3a2f09", + "core_candidate_run": 33675068951, + "tested_os_version": "v0.7.12-dev", + "tested_os_bundle_sha256": "RECORD_64_HEX_OS_BUNDLE_SHA256", + "tested_on": "RECORD_TEST_NODE", + "tested_at": "RECORD_ISO_8601_TIMESTAMP", + "acceptance": { + "observed_at": "RECORD_ISO_8601_TIMESTAMP", + "chain": "main", + "core_version": "RECORD_INTEGER_VERSION", + "migration_required_marker_absent": "RECORD_BOOLEAN", + "migration_started_marker_valid": "RECORD_BOOLEAN", + "migration_complete_marker_valid": "RECORD_BOOLEAN", + "checkpoint_height": 57752, + "checkpoint_hash": "000000000000000013ceffe797280c57f75a5b9f1d9e70c3503584058c322576", + "chainwork": "RECORD_64_HEX_CHAINWORK", + "ibd": false, + "verification_progress": "RECORD_NUMBER", + "blocks": "RECORD_INTEGER", + "headers": "RECORD_SAME_INTEGER", + "best_block_hash": "RECORD_64_HEX_HASH", + "explorer_common_height": "RECORD_SAME_INTEGER", + "explorer_common_hash": "RECORD_SAME_64_HEX_HASH", + "outbound_core31_peers": "RECORD_INTEGER_AT_LEAST_3", + "competing_valid_tips": 0, + "verifychain_level": 4, + "verifychain_passed": "RECORD_BOOLEAN", + "payout_configured": "RECORD_BOOLEAN", + "payout_preserved": "RECORD_BOOLEAN", + "pool_stratum_result": "RECORD_passed", + "app_ui_privacy_passed": "RECORD_BOOLEAN", + "telemetry_disabled": "RECORD_BOOLEAN", + "p2p_port_unpublished": "RECORD_BOOLEAN", + "natpmp_disabled": "RECORD_BOOLEAN", + "post_completion_restart_passed": "RECORD_BOOLEAN", + "reindex_not_repeated": "RECORD_BOOLEAN", + "app_rollback_rejected": "RECORD_BOOLEAN", + "os_rollback_rejected": "RECORD_BOOLEAN" + } +} diff --git a/willitmod-dev-bc2/docker-compose.yml b/willitmod-dev-bc2/docker-compose.yml index a002422..4b4f5f6 100644 --- a/willitmod-dev-bc2/docker-compose.yml +++ b/willitmod-dev-bc2/docker-compose.yml @@ -54,7 +54,7 @@ services: - exec /bin/sh /opt/axebc2/init.sh btc2d: - image: ghcr.io/willitmod/bitcoinii-core:31.1.0-rc.cdf44542dde2@sha256:CORE31_CANDIDATE_DIGEST_REQUIRED + image: ghcr.io/willitmod/bitcoinii-core:31.1.0-rc.cdf44542dde2@sha256:8875917ece57668fe9925d40a256ce8d429a3071511bb555d4ace1fa4370afc6 user: "1000:1000" restart: unless-stopped stop_grace_period: 15m30s @@ -110,7 +110,7 @@ services: fi app: - image: ghcr.io/willitmod/axebc2-app-umbrel-dev:0.1.10-candidate.6e4ef58218e8@sha256:APP_CANDIDATE_DIGEST_REQUIRED + image: ghcr.io/willitmod/axebc2-app-umbrel-dev:0.1.10-candidate.6e4ef58218e8@sha256:b7ba2df2f48389d145ad18a927b099f32b5aa7708a0ea617a1b04e25c8e7f961 user: "1000:1000" restart: on-failure stop_grace_period: 30s @@ -136,7 +136,7 @@ services: STATIC_DIR: "/app/static" APP_CHANNEL: "ALPHA" APP_VERSION_SUFFIX: "-dev" - BTC2D_IMAGE: "ghcr.io/willitmod/bitcoinii-core:31.1.0-rc.cdf44542dde2@sha256:CORE31_CANDIDATE_DIGEST_REQUIRED" + BTC2D_IMAGE: "ghcr.io/willitmod/bitcoinii-core:31.1.0-rc.cdf44542dde2@sha256:8875917ece57668fe9925d40a256ce8d429a3071511bb555d4ace1fa4370afc6" CKPOOL_IMAGE: "ghcr.io/willitmod/docker-ckpool-solo:590fb2a@sha256:8a9a7f10c8138d0f55533132ee7710a06715a42a49f75efb39be3350ada4fa6e" SUPPORT_CHECKIN_ENABLED: "false" BTC2_RPC_HOST: "btc2d" From 3a8e734d3bc49df402f3bb066af8e7633830dc48 Mon Sep 17 00:00:00 2001 From: Johnny Murray Date: Wed, 2 Sep 2026 21:02:25 +0100 Subject: [PATCH 16/18] Handle hosted Compose bind normalization --- scripts/axebc2_release_state.py | 34 +++++++++++++++++++++++++++ scripts/validate-axebc2-core31-dev.py | 21 ++++++++--------- tests/test_axebc2_release_state.py | 11 ++++++++- 3 files changed, 54 insertions(+), 12 deletions(-) diff --git a/scripts/axebc2_release_state.py b/scripts/axebc2_release_state.py index e5d10fb..e8ef5c5 100644 --- a/scripts/axebc2_release_state.py +++ b/scripts/axebc2_release_state.py @@ -1,4 +1,5 @@ import re +from pathlib import Path APP_TAG = "ghcr.io/willitmod/axebc2-app-umbrel-dev:0.1.10-candidate.6e4ef58218e8" CORE_TAG = "ghcr.io/willitmod/bitcoinii-core:31.1.0-rc.cdf44542dde2" @@ -20,3 +21,36 @@ def validate(compose, phase): core = re.findall(re.escape(CORE_TAG) + r"@(sha256:[0-9a-f]{64})", compose) if len(app) != 1 or len(core) != 2 or len(set(core)) != 1: raise ValueError("finalized release requires one app pin and two identical Core pins") + +def validate_rendered_binds(contract, rendered, environment=None): + environment = environment or {} + def expand(value): + if not isinstance(value, str): return value + for name, replacement in environment.items(): + value = value.replace("${" + name + "}", replacement) + return value + def binds(document): + for service, config in document.get("services", {}).items(): + for volume in config.get("volumes", []): + if volume.get("type") == "bind": + yield service, volume + expected = {} + for service, volume in binds(contract): + key = (service, expand(volume.get("source")), volume.get("target")) + if volume.get("bind", {}).get("create_host_path") is not False: + raise ValueError(f"contract bind is not fail-closed: service={service} source={key[1]} target={key[2]}") + expected[key] = volume + actual = {} + for service, volume in binds(rendered): + key = (service, volume.get("source"), volume.get("target")) + if key not in expected: + raise ValueError(f"unexpected rendered bind: service={service} source={key[1]} target={key[2]}") + if volume.get("bind", {}).get("create_host_path") is True: + raise ValueError(f"rendered bind enables host-path creation: service={service} source={key[1]} target={key[2]}") + if not isinstance(key[1], str) or not Path(key[1]).exists(): + raise ValueError(f"rendered bind source was not pre-staged: service={service} source={key[1]} target={key[2]}") + actual[key] = volume + missing = set(expected) - set(actual) + if missing: + service, source, target = sorted(missing)[0] + raise ValueError(f"rendered bind disappeared: service={service} source={source} target={target}") diff --git a/scripts/validate-axebc2-core31-dev.py b/scripts/validate-axebc2-core31-dev.py index 106a482..0d945ba 100644 --- a/scripts/validate-axebc2-core31-dev.py +++ b/scripts/validate-axebc2-core31-dev.py @@ -10,7 +10,7 @@ import tempfile import unittest import argparse -from axebc2_release_state import validate as validate_release_state +from axebc2_release_state import validate as validate_release_state, validate_rendered_binds ROOT = Path(__file__).resolve().parents[1] @@ -91,10 +91,12 @@ def validate_platform_merged_compose(): (app_data / relative).mkdir(parents=True, exist_ok=True) (app_data / "data/init/init.sh").write_text("#!/bin/sh\n", encoding="utf-8") source = temp / "docker-compose.yml" + build_metadata = temp / "build.json" + build_metadata.write_text('{"tag":"v0.7.12-dev"}\n', encoding="utf-8") source.write_text( - compose.replace("CORE31_CANDIDATE_DIGEST_REQUIRED", "a" * 64).replace( - "APP_CANDIDATE_DIGEST_REQUIRED", "b" * 64 - ), + compose.replace("CORE31_CANDIDATE_DIGEST_REQUIRED", "a" * 64) + .replace("APP_CANDIDATE_DIGEST_REQUIRED", "b" * 64) + .replace("/etc/5tratumos/build.json", str(build_metadata)), encoding="utf-8", ) parsed = temp / "parsed-compose.json" @@ -158,13 +160,10 @@ def validate_platform_merged_compose(): == "service_completed_successfully", "Core must wait for successful init completion", ) - for service in services.values(): - for volume in service.get("volumes", []): - if volume.get("type") == "bind": - require( - volume.get("bind", {}).get("create_host_path") is False, - "rendered Compose contains an implicit host-path bind", - ) + try: + validate_rendered_binds(rendered_contract, rendered, {"APP_DATA_DIR": str(app_data)}) + except ValueError as exc: + raise SystemExit(str(exc)) validate_platform_merged_compose() diff --git a/tests/test_axebc2_release_state.py b/tests/test_axebc2_release_state.py index 7c6aedb..d30c05c 100644 --- a/tests/test_axebc2_release_state.py +++ b/tests/test_axebc2_release_state.py @@ -2,7 +2,7 @@ from pathlib import Path import unittest sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) -from axebc2_release_state import APP_TAG, CORE_TAG, validate +from axebc2_release_state import APP_TAG, CORE_TAG, validate, validate_rendered_binds class ReleaseStateTests(unittest.TestCase): def test_only_complete_prefinalization_is_accepted(self): @@ -21,3 +21,12 @@ def test_lifecycle_matrix_rejects_cross_phase_validation(self): validate(pre,"prefinalization"); validate(final,"finalized") with self.assertRaises(ValueError): validate(pre,"finalized") with self.assertRaises(ValueError): validate(final,"prefinalization") + def test_hosted_compose_may_omit_false_bind_metadata(self): + import tempfile + with tempfile.TemporaryDirectory() as source: + contract={"services":{"init":{"volumes":[{"type":"bind","source":source,"target":"/data","bind":{"create_host_path":False}}]}}} + hosted={"services":{"init":{"volumes":[{"type":"bind","source":source,"target":"/data"}]}}} + validate_rendered_binds(contract,hosted) + hosted["services"]["init"]["volumes"][0]["bind"]={"create_host_path":True} + with self.assertRaisesRegex(ValueError,"service=init.*source=.*target=/data"): + validate_rendered_binds(contract,hosted) From 959770ec2cdc9d6e7cd3e6904afc6cea168f1f52 Mon Sep 17 00:00:00 2001 From: Johnny Murray Date: Wed, 2 Sep 2026 21:09:01 +0100 Subject: [PATCH 17/18] Bind AxeBC2 DEV to verified OS bundle --- scripts/finalize-axebc2-0.1.10-dev.sh | 11 +++--- scripts/validate-axebc2-core31-dev.py | 7 ++++ tests/test_axebc2_dev_finalizer.py | 3 ++ willitmod-dev-bc2/CORE31-DEV-RELEASE.md | 35 +++++++++++-------- .../DEV-ACCEPTANCE-EVIDENCE.json | 2 +- 5 files changed, 38 insertions(+), 20 deletions(-) diff --git a/scripts/finalize-axebc2-0.1.10-dev.sh b/scripts/finalize-axebc2-0.1.10-dev.sh index 61e79ee..ca9bab6 100755 --- a/scripts/finalize-axebc2-0.1.10-dev.sh +++ b/scripts/finalize-axebc2-0.1.10-dev.sh @@ -16,6 +16,8 @@ app_tag="ghcr.io/willitmod/axebc2-app-umbrel-dev:0.1.10-candidate.6e4ef58218e8" app_revision="6e4ef58218e8cd5a4d1113196f9872a7f501f52e" core_revision="cdf44542dde255648008249d187fafc15f3a2f09" core_tag="ghcr.io/willitmod/bitcoinii-core:$core_candidate_tag" +os_version="v0.7.12-dev" +os_bundle_sha256="11a35e68ab169eb0446485992a57b33fae018a92020b7d86bbf9a005571377af" fail() { echo "ERROR: $*" >&2; exit 1; } [[ "$app_digest" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "app digest is not an exact sha256 digest" [[ "$core_digest" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "Core digest is not an exact sha256 digest" @@ -76,12 +78,13 @@ grep -Fx " image: $core_tag@$core_digest" "$tmp" >/dev/null || fail "Core ser grep -Fx " BTC2D_IMAGE: \"$core_tag@$core_digest\"" "$tmp" >/dev/null || fail "BTC2D_IMAGE is incorrect" evidence_tmp="$(mktemp "${evidence_output}.finalize.XXXXXX")" -python3 - "$evidence_tmp" "$app_tag" "$app_digest" "$app_revision" "$core_tag" "$core_digest" "$core_revision" <<'PY' +python3 - "$evidence_tmp" "$app_tag" "$app_digest" "$app_revision" "$core_tag" "$core_digest" "$core_revision" "$os_version" "$os_bundle_sha256" <<'PY' import json,sys -path,app_image,app_digest,revision,core_image,core_digest,core_revision=sys.argv[1:] +path,app_image,app_digest,revision,core_image,core_digest,core_revision,os_version,os_bundle_sha256=sys.argv[1:] with open(path,"w",encoding="utf-8") as h: - json.dump({"schema":1,"result":"RECORD_passed_AFTER_LIVE_DEV_ACCEPTANCE","app_image":app_image,"app_digest":app_digest,"core_image":core_image,"core_digest":core_digest,"app_version":"0.1.10-dev","source_revision":revision,"core_source_revision":core_revision,"core_candidate_run":33675068951,"tested_os_version":"v0.7.12-dev","tested_os_bundle_sha256":"RECORD_64_HEX_OS_BUNDLE_SHA256","tested_on":"RECORD_TEST_NODE","tested_at":"RECORD_ISO_8601_TIMESTAMP","acceptance":{"observed_at":"RECORD_ISO_8601_TIMESTAMP","chain":"main","core_version":"RECORD_INTEGER_VERSION","migration_required_marker_absent":"RECORD_BOOLEAN","migration_started_marker_valid":"RECORD_BOOLEAN","migration_complete_marker_valid":"RECORD_BOOLEAN","checkpoint_height":57752,"checkpoint_hash":"000000000000000013ceffe797280c57f75a5b9f1d9e70c3503584058c322576","chainwork":"RECORD_64_HEX_CHAINWORK","ibd":False,"verification_progress":"RECORD_NUMBER","blocks":"RECORD_INTEGER","headers":"RECORD_SAME_INTEGER","best_block_hash":"RECORD_64_HEX_HASH","explorer_common_height":"RECORD_SAME_INTEGER","explorer_common_hash":"RECORD_SAME_64_HEX_HASH","outbound_core31_peers":"RECORD_INTEGER_AT_LEAST_3","competing_valid_tips":0,"verifychain_level":4,"verifychain_passed":"RECORD_BOOLEAN","payout_configured":"RECORD_BOOLEAN","payout_preserved":"RECORD_BOOLEAN","pool_stratum_result":"RECORD_passed","app_ui_privacy_passed":"RECORD_BOOLEAN","telemetry_disabled":"RECORD_BOOLEAN","p2p_port_unpublished":"RECORD_BOOLEAN","natpmp_disabled":"RECORD_BOOLEAN","post_completion_restart_passed":"RECORD_BOOLEAN","reindex_not_repeated":"RECORD_BOOLEAN","app_rollback_rejected":"RECORD_BOOLEAN","os_rollback_rejected":"RECORD_BOOLEAN"}},h,indent=2); h.write("\n") + json.dump({"schema":1,"result":"RECORD_passed_AFTER_LIVE_DEV_ACCEPTANCE","app_image":app_image,"app_digest":app_digest,"core_image":core_image,"core_digest":core_digest,"app_version":"0.1.10-dev","source_revision":revision,"core_source_revision":core_revision,"core_candidate_run":33675068951,"tested_os_version":os_version,"tested_os_bundle_sha256":os_bundle_sha256,"tested_on":"RECORD_TEST_NODE","tested_at":"RECORD_ISO_8601_TIMESTAMP","acceptance":{"observed_at":"RECORD_ISO_8601_TIMESTAMP","chain":"main","core_version":"RECORD_INTEGER_VERSION","migration_required_marker_absent":"RECORD_BOOLEAN","migration_started_marker_valid":"RECORD_BOOLEAN","migration_complete_marker_valid":"RECORD_BOOLEAN","checkpoint_height":57752,"checkpoint_hash":"000000000000000013ceffe797280c57f75a5b9f1d9e70c3503584058c322576","chainwork":"RECORD_64_HEX_CHAINWORK","ibd":False,"verification_progress":"RECORD_NUMBER","blocks":"RECORD_INTEGER","headers":"RECORD_SAME_INTEGER","best_block_hash":"RECORD_64_HEX_HASH","explorer_common_height":"RECORD_SAME_INTEGER","explorer_common_hash":"RECORD_SAME_64_HEX_HASH","outbound_core31_peers":"RECORD_INTEGER_AT_LEAST_3","competing_valid_tips":0,"verifychain_level":4,"verifychain_passed":"RECORD_BOOLEAN","payout_configured":"RECORD_BOOLEAN","payout_preserved":"RECORD_BOOLEAN","pool_stratum_result":"RECORD_passed","app_ui_privacy_passed":"RECORD_BOOLEAN","telemetry_disabled":"RECORD_BOOLEAN","p2p_port_unpublished":"RECORD_BOOLEAN","natpmp_disabled":"RECORD_BOOLEAN","post_completion_restart_passed":"RECORD_BOOLEAN","reindex_not_repeated":"RECORD_BOOLEAN","app_rollback_rejected":"RECORD_BOOLEAN","os_rollback_rejected":"RECORD_BOOLEAN"}},h,indent=2); h.write("\n") PY chmod 0644 "$evidence_tmp" mv -f "$tmp" "$compose"; mv -f "$evidence_tmp" "$evidence_output" -printf 'Prepared AxeBC2 0.1.10 DEV\napp=%s\ncore=%s\nevidence template=%s\n' "$app_digest" "$core_digest" "$evidence_output" +printf 'Prepared AxeBC2 0.1.10 DEV\napp=%s\ncore=%s\nOS=%s (%s)\nevidence template=%s\n' \ + "$app_digest" "$core_digest" "$os_version" "$os_bundle_sha256" "$evidence_output" diff --git a/scripts/validate-axebc2-core31-dev.py b/scripts/validate-axebc2-core31-dev.py index 0d945ba..71a9811 100644 --- a/scripts/validate-axebc2-core31-dev.py +++ b/scripts/validate-axebc2-core31-dev.py @@ -32,9 +32,16 @@ def require(condition, message): raise SystemExit(str(exc)) manifest = (APP / "umbrel-app.yml").read_text(encoding="utf-8") node_config = (APP / "data/templates/bitcoinII.conf.template").read_text(encoding="utf-8") +evidence = json.loads((APP / "DEV-ACCEPTANCE-EVIDENCE.json").read_text(encoding="utf-8")) require('version: "0.1.10-dev"' in manifest, "manifest must be 0.1.10-dev") require("Requires 5tratumOS 0.7.12" in manifest, "OS prerequisite must be disclosed") +require(evidence.get("tested_os_version") == "v0.7.12-dev", "evidence must name the tested DEV OS release") +require( + evidence.get("tested_os_bundle_sha256") + == "11a35e68ab169eb0446485992a57b33fae018a92020b7d86bbf9a005571377af", + "evidence must be bound to the exact verified v0.7.12-dev bundle", +) require('"2345:3333/tcp"' in compose, "Stratum host port 2345 must be retained") require("SUPPORT_CHECKIN_ENABLED: \"false\"" in compose, "telemetry must default off") require("create_host_path: false" in compose, "build metadata bind must fail closed") diff --git a/tests/test_axebc2_dev_finalizer.py b/tests/test_axebc2_dev_finalizer.py index 54e2d56..ae0544b 100644 --- a/tests/test_axebc2_dev_finalizer.py +++ b/tests/test_axebc2_dev_finalizer.py @@ -13,6 +13,7 @@ APP_DIGEST = "sha256:" + "a" * 64 CORE_DIGEST = "sha256:" + "b" * 64 CORE_TAG = "31.1.0-rc.cdf44542dde2" +OS_BUNDLE_SHA256 = "11a35e68ab169eb0446485992a57b33fae018a92020b7d86bbf9a005571377af" class AxeBC2DevFinalizerTests(unittest.TestCase): def setUp(self): @@ -83,6 +84,8 @@ def test_anonymous_candidate_checks_finalize_and_emit_evidence(self): self.assertEqual(evidence["source_revision"],"6e4ef58218e8cd5a4d1113196f9872a7f501f52e") self.assertEqual(evidence["core_source_revision"],"cdf44542dde255648008249d187fafc15f3a2f09") self.assertEqual(evidence["core_candidate_run"],33675068951) + self.assertEqual(evidence["tested_os_version"],"v0.7.12-dev") + self.assertEqual(evidence["tested_os_bundle_sha256"],OS_BUNDLE_SHA256) self.assertEqual(evidence["app_digest"],APP_DIGEST); self.assertEqual(evidence["core_digest"],CORE_DIGEST) calls=self.log.read_text(encoding="utf-8") self.assertEqual(calls.count("--platform linux/amd64"),2); self.assertEqual(calls.count("--platform linux/arm64"),2) diff --git a/willitmod-dev-bc2/CORE31-DEV-RELEASE.md b/willitmod-dev-bc2/CORE31-DEV-RELEASE.md index e594179..0838e24 100644 --- a/willitmod-dev-bc2/CORE31-DEV-RELEASE.md +++ b/willitmod-dev-bc2/CORE31-DEV-RELEASE.md @@ -15,21 +15,18 @@ persistent app-data or node-data mutation and prevents Core from starting. A future dedicated, independently built and digest-pinned init image could remove that availability dependency; it is not introduced in this consensus release. -The committed Compose file deliberately retains these non-runnable sentinels: - -- `CORE31_CANDIDATE_DIGEST_REQUIRED` -- `APP_CANDIDATE_DIGEST_REQUIRED` - -CI treats this as the strict `prefinalization` phase. It accepts exactly all -three expected sentinel occurrences. Once finalization is committed, CI -switches to `finalized` and requires one immutable application sha256 pin and -two identical immutable Core sha256 pins. A partial or mixed state is rejected. - -They must be replaced with the exact verified multi-architecture candidate -digests. After substitution, the merged platform Compose must pass validation, -all images must pull anonymously by digest, init must complete successfully on -5tratumOS 0.7.12+, and the resulting installation must be tested on DEV before -any production promotion. +The committed Compose file is finalized: it contains one immutable application +sha256 pin and two identical immutable Core sha256 pins, with no digest +sentinels. CI detects this as the strict `finalized` phase. The earlier +`prefinalization` phase accepted exactly one `APP_CANDIDATE_DIGEST_REQUIRED` and +two `CORE31_CANDIDATE_DIGEST_REQUIRED` occurrences; a partial or mixed state is +rejected in either phase. + +Finalization replaced those sentinels with the exact verified +multi-architecture candidate digests. The merged platform Compose must pass +validation, all images must pull anonymously by digest, init must complete +successfully on 5tratumOS 0.7.12+, and the resulting installation must be +tested on DEV before any production promotion. Run `scripts/finalize-axebc2-0.1.10-dev.sh` with the exact application index digest, exact Core candidate tag and exact Core index digest. The application @@ -43,6 +40,14 @@ and pulls. It atomically replaces every sentinel and emits both exact source revisions in the evidence JSON template, which must be completed only after live DEV acceptance. +The test platform is also fixed to the published DEV-only +[`v0.7.12-dev`](https://github.com/WillItMod/5tratum/releases/tag/v0.7.12-dev) +bundle with SHA-256 +`11a35e68ab169eb0446485992a57b33fae018a92020b7d86bbf9a005571377af`. +The finalizer writes that exact value into the acceptance template; it is not a +free-form observation. MAIN promotion rejects evidence from a different OS +bundle even when the displayed version string is the same. + The store validator exercises a pinned copy of the relevant 5tratumOS materialization contract from platform commit `4f979cb9541622c1fdccdf43b8a885bbf845ba38`: it consumes `app_proxy`, publishes the manifest port on the resolved app diff --git a/willitmod-dev-bc2/DEV-ACCEPTANCE-EVIDENCE.json b/willitmod-dev-bc2/DEV-ACCEPTANCE-EVIDENCE.json index 2ea9152..553d20c 100644 --- a/willitmod-dev-bc2/DEV-ACCEPTANCE-EVIDENCE.json +++ b/willitmod-dev-bc2/DEV-ACCEPTANCE-EVIDENCE.json @@ -10,7 +10,7 @@ "core_source_revision": "cdf44542dde255648008249d187fafc15f3a2f09", "core_candidate_run": 33675068951, "tested_os_version": "v0.7.12-dev", - "tested_os_bundle_sha256": "RECORD_64_HEX_OS_BUNDLE_SHA256", + "tested_os_bundle_sha256": "11a35e68ab169eb0446485992a57b33fae018a92020b7d86bbf9a005571377af", "tested_on": "RECORD_TEST_NODE", "tested_at": "RECORD_ISO_8601_TIMESTAMP", "acceptance": { From 63ce6f945d5cc6769bf704aa870b610d7418e051 Mon Sep 17 00:00:00 2001 From: Johnny Murray Date: Fri, 4 Sep 2026 14:58:53 +0100 Subject: [PATCH 18/18] Record AxeBC2 Core 31 live acceptance --- willitmod-dev-bc2/CORE31-DEV-RELEASE.md | 22 ++++++++ .../DEV-ACCEPTANCE-EVIDENCE.json | 56 +++++++++---------- 2 files changed, 50 insertions(+), 28 deletions(-) diff --git a/willitmod-dev-bc2/CORE31-DEV-RELEASE.md b/willitmod-dev-bc2/CORE31-DEV-RELEASE.md index 0838e24..a52405a 100644 --- a/willitmod-dev-bc2/CORE31-DEV-RELEASE.md +++ b/willitmod-dev-bc2/CORE31-DEV-RELEASE.md @@ -57,3 +57,25 @@ update commands, so invoking the live implementation from isolated store CI would require performing a stateful platform transaction. Final DEV acceptance therefore still runs the real platform materializer and validates its generated Compose file before containers are started. + +## Live DEV acceptance + +The exact finalized candidate was accepted on `10.10.10.235` using the pinned +5tratumOS `v0.7.12-dev` bundle on 2026-09-04. The mandatory Core 31 full reindex +completed, its protected migration markers validated, and a subsequent full app +restart did not repeat the reindex. Core reported version `310100`, completed a +level-4 `verifychain`, and matched the official BitcoinII explorer at height +58,433 and block hash +`0000000000000001077a5ea39eefb3a44e5d88357c723f56484840a7f89c5554`. + +Five outbound BitcoinII Core 31 peers were observed. Six historical one-block +header branches ended between heights 53,093 and 53,209, all before the +ShockWave checkpoint; no non-active valid tip existed at or beyond checkpoint +height 57,752, so the recorded number of competing valid tips is zero. + +The non-submitting Stratum probe received subscribe, authorize, difficulty and +job notifications. The configured payout was compared using a private HMAC and +remained unchanged. The UI/privacy checks, telemetry and port-exposure checks, +post-completion restart, app rollback rejection and OS rollback rejection all +passed. All 39 unrelated application containers retained their preflight image +and container identifiers. diff --git a/willitmod-dev-bc2/DEV-ACCEPTANCE-EVIDENCE.json b/willitmod-dev-bc2/DEV-ACCEPTANCE-EVIDENCE.json index 553d20c..8aa75a6 100644 --- a/willitmod-dev-bc2/DEV-ACCEPTANCE-EVIDENCE.json +++ b/willitmod-dev-bc2/DEV-ACCEPTANCE-EVIDENCE.json @@ -1,6 +1,6 @@ { "schema": 1, - "result": "RECORD_passed_AFTER_LIVE_DEV_ACCEPTANCE", + "result": "passed", "app_image": "ghcr.io/willitmod/axebc2-app-umbrel-dev:0.1.10-candidate.6e4ef58218e8", "app_digest": "sha256:b7ba2df2f48389d145ad18a927b099f32b5aa7708a0ea617a1b04e25c8e7f961", "core_image": "ghcr.io/willitmod/bitcoinii-core:31.1.0-rc.cdf44542dde2", @@ -11,39 +11,39 @@ "core_candidate_run": 33675068951, "tested_os_version": "v0.7.12-dev", "tested_os_bundle_sha256": "11a35e68ab169eb0446485992a57b33fae018a92020b7d86bbf9a005571377af", - "tested_on": "RECORD_TEST_NODE", - "tested_at": "RECORD_ISO_8601_TIMESTAMP", + "tested_on": "10.10.10.235", + "tested_at": "2026-09-04T13:57:46Z", "acceptance": { - "observed_at": "RECORD_ISO_8601_TIMESTAMP", + "observed_at": "2026-09-04T13:57:46Z", "chain": "main", - "core_version": "RECORD_INTEGER_VERSION", - "migration_required_marker_absent": "RECORD_BOOLEAN", - "migration_started_marker_valid": "RECORD_BOOLEAN", - "migration_complete_marker_valid": "RECORD_BOOLEAN", + "core_version": 310100, + "migration_required_marker_absent": true, + "migration_started_marker_valid": true, + "migration_complete_marker_valid": true, "checkpoint_height": 57752, "checkpoint_hash": "000000000000000013ceffe797280c57f75a5b9f1d9e70c3503584058c322576", - "chainwork": "RECORD_64_HEX_CHAINWORK", + "chainwork": "00000000000000000000000000000000000000000000fb0eacdb04473f61a89b", "ibd": false, - "verification_progress": "RECORD_NUMBER", - "blocks": "RECORD_INTEGER", - "headers": "RECORD_SAME_INTEGER", - "best_block_hash": "RECORD_64_HEX_HASH", - "explorer_common_height": "RECORD_SAME_INTEGER", - "explorer_common_hash": "RECORD_SAME_64_HEX_HASH", - "outbound_core31_peers": "RECORD_INTEGER_AT_LEAST_3", + "verification_progress": 1, + "blocks": 58433, + "headers": 58433, + "best_block_hash": "0000000000000001077a5ea39eefb3a44e5d88357c723f56484840a7f89c5554", + "explorer_common_height": 58433, + "explorer_common_hash": "0000000000000001077a5ea39eefb3a44e5d88357c723f56484840a7f89c5554", + "outbound_core31_peers": 5, "competing_valid_tips": 0, "verifychain_level": 4, - "verifychain_passed": "RECORD_BOOLEAN", - "payout_configured": "RECORD_BOOLEAN", - "payout_preserved": "RECORD_BOOLEAN", - "pool_stratum_result": "RECORD_passed", - "app_ui_privacy_passed": "RECORD_BOOLEAN", - "telemetry_disabled": "RECORD_BOOLEAN", - "p2p_port_unpublished": "RECORD_BOOLEAN", - "natpmp_disabled": "RECORD_BOOLEAN", - "post_completion_restart_passed": "RECORD_BOOLEAN", - "reindex_not_repeated": "RECORD_BOOLEAN", - "app_rollback_rejected": "RECORD_BOOLEAN", - "os_rollback_rejected": "RECORD_BOOLEAN" + "verifychain_passed": true, + "payout_configured": true, + "payout_preserved": true, + "pool_stratum_result": "passed", + "app_ui_privacy_passed": true, + "telemetry_disabled": true, + "p2p_port_unpublished": true, + "natpmp_disabled": true, + "post_completion_restart_passed": true, + "reindex_not_repeated": true, + "app_rollback_rejected": true, + "os_rollback_rejected": true } }