Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/validate-axebc2-core31-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ on:
- "tests/test_axebc2_platform_integration.py"
- "tests/test_axebc2_dev_finalizer.py"
- "tests/test_axebc2_release_state.py"
- "tests/test_axebc2_umbrel.py"
- "scripts/build-axebc2-umbrel.py"
- "tests/fixtures/5tratumos_contract_4f979cb.py"
- "scripts/validate-axebc2-core31-dev.py"
- "scripts/axebc2_release_state.py"
Expand All @@ -21,6 +23,8 @@ on:
- "tests/test_axebc2_platform_integration.py"
- "tests/test_axebc2_dev_finalizer.py"
- "tests/test_axebc2_release_state.py"
- "tests/test_axebc2_umbrel.py"
- "scripts/build-axebc2-umbrel.py"
- "tests/fixtures/5tratumos_contract_4f979cb.py"
- "scripts/validate-axebc2-core31-dev.py"
- "scripts/axebc2_release_state.py"
Expand Down
4 changes: 2 additions & 2 deletions scripts/axebc2_release_state.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from pathlib import Path

APP_TAG = "ghcr.io/willitmod/axebc2-app-umbrel-dev:0.1.11-candidate.ecf6e2c8cfd0"
APP_DIGEST = "sha256:23a7962e223da5549eba52697c6f4cfa16ab74cba935c68c48148a4c515302b4"
APP_TAG = "ghcr.io/willitmod/axebc2-app-umbrel-dev:0.1.12-candidate.3b893173de7b"
APP_DIGEST = "sha256:5defc8ac3c1d6e188959ed5c0642165e66108701c5ce3881e909c0994c8e3189"
CORE_TAG = "ghcr.io/willitmod/bitcoinii-core:31.1.0-rc.cdf44542dde2"
CORE_DIGEST = "sha256:8875917ece57668fe9925d40a256ce8d429a3071511bb555d4ace1fa4370afc6"

Expand Down
78 changes: 78 additions & 0 deletions scripts/build-axebc2-umbrel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Generate Umbrel's native template without changing the accepted OS recipe.

Umbrel renders top-level *.template files and updates hooks on app upgrades.
5tratumOS consumes docker-compose.yml directly and preserves seeded data.
Keep the accepted initializer as the common source for migration/config logic.
"""
import argparse
from pathlib import Path
import yaml

ROOT = Path(__file__).resolve().parents[1]
APP = ROOT / "willitmod-dev-bc2"


def artifacts():
compose = yaml.safe_load((APP / "docker-compose.yml").read_text())
init = (APP / "data/init/init.sh").read_text()
start = init.index("# This check deliberately precedes every write")
end = init.index("\natomic_json_write()", start)
# This entrypoint is selected only by Umbrel's native template path. The
# default 5tratumOS initializer and its OS version check remain untouched.
init = init[:start] + (
'# Umbrel owns the app lifecycle; the Core 31 migration checks below\n'
'# are app-owned and remain mandatory on this platform.\n'
'[ "${AXEBC2_PLATFORM:-}" = "umbrel" ] || fail "Umbrel entrypoint requires the Umbrel recipe"\n'
) + init[end:]
init = init.replace(
"# Normal initialization starts only after the OS floor and migration policy exist.",
"# Normal initialization starts only after the migration policy exists.",
)
init = init.replace(
'build_file="${AXEBC2_BUILD_FILE:-/etc/5tratumos/build.json}"\n', ""
)
generated = "# Generated by scripts/build-axebc2-umbrel.py; do not edit.\n"
init = init.replace("#!/bin/sh\n", "#!/bin/sh\n" + generated, 1)
services = compose["services"]
svc = services["init"]
svc["volumes"] = [v for v in svc["volumes"] if v["target"] != "/etc/5tratumos/build.json"]
for volume in svc["volumes"]:
if volume["target"] == "/opt/axebc2/init.sh":
volume["source"] = "${APP_DATA_DIR}/hooks/umbrel-init"
svc["environment"]["AXEBC2_PLATFORM"] = "umbrel"
svc["command"] = ["/bin/sh", "/opt/axebc2/init.sh"]
# No shell program is embedded in an envsubst template: unexported local
# variables would otherwise disappear before Docker sees the command.
pool = services["ckpool"]
pool_script = "#!/bin/sh\n" + generated + pool["entrypoint"][-1].replace("$$ARGS", "$ARGS") + "\n"
pool["entrypoint"] = ["/bin/sh", "/opt/axebc2/ckpool-entrypoint.sh"]
pool["volumes"].append({
"type": "bind", "source": "${APP_DATA_DIR}/hooks/umbrel-ckpool",
"target": "/opt/axebc2/ckpool-entrypoint.sh", "read_only": True,
"bind": {"create_host_path": False},
})
return {
APP / "docker-compose.yml.template": generated + yaml.safe_dump(compose, sort_keys=False),
APP / "hooks/umbrel-init": init,
APP / "hooks/umbrel-ckpool": pool_script,
}


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--check", action="store_true")
args = parser.parse_args()
for path, text in artifacts().items():
if args.check:
if not path.is_file() or path.read_text() != text:
raise SystemExit(f"Outdated generated Umbrel artifact: {path.relative_to(ROOT)}")
else:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text)
# These are container entrypoints, not executable host hooks.
path.chmod(0o644)


if __name__ == "__main__":
main()
25 changes: 16 additions & 9 deletions scripts/validate-axebc2-core31-dev.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import tempfile
import unittest
import argparse
from axebc2_release_state import validate as validate_release_state, validate_rendered_binds
from axebc2_release_state import validate as validate_release_state, validate_rendered_binds, APP_TAG as CURRENT_APP_TAG, APP_DIGEST as CURRENT_APP_DIGEST


ROOT = Path(__file__).resolve().parents[1]
Expand Down Expand Up @@ -42,18 +42,25 @@ def require(condition, message):
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"))

# Hash the exact finalized recipe in either lifecycle phase. In prefinalization
# there is exactly one sentinel; in finalization this replacement is a no-op.
finalized_compose_bytes = compose_bytes.replace(
b"APP_CANDIDATE_DIGEST_REQUIRED", APP_DIGEST.removeprefix("sha256:").encode()
# Preserve the original Core 31 acceptance binding. This release changes only
# the UI image and DEV stage; undo those two explicit changes for the baseline
# check, so any unrelated runtime/configuration change is still rejected.
finalized_compose = compose.replace(
"APP_CANDIDATE_DIGEST_REQUIRED", CURRENT_APP_DIGEST.removeprefix("sha256:")
)
computed_compose_sha256 = hashlib.sha256(finalized_compose_bytes).hexdigest()
require('APP_CHANNEL: "BETA"' in finalized_compose, "DEV stage must be BETA")
baseline_compose = finalized_compose.replace(
CURRENT_APP_TAG + "@" + CURRENT_APP_DIGEST,
"ghcr.io/willitmod/axebc2-app-umbrel-dev:0.1.11-candidate.ecf6e2c8cfd0@" + APP_DIGEST,
).replace('APP_CHANNEL: "BETA"', 'APP_CHANNEL: "ALPHA"')
computed_compose_sha256 = hashlib.sha256(baseline_compose.encode()).hexdigest()
require(
computed_compose_sha256 == DEV_COMPOSE_SHA256,
"DEV Compose content differs from the recipe accepted on 10.10.10.235",
"DEV runtime differs from its accepted baseline beyond the BETA UI image/stage",
)

require('version: "0.1.11-dev"' in manifest, "manifest must be 0.1.11-dev")
# BETA release; the historical Core 31 baseline evidence below remains 0.1.11-dev.
require('version: "0.1.12-dev"' in manifest, "manifest must be 0.1.12-dev")
require(evidence.get("app_version") == "0.1.11-dev", "evidence must name the 0.1.11 DEV app version")
require(
evidence.get("app_image")
Expand All @@ -78,7 +85,7 @@ def require(condition, message):
and evidence.get("core_candidate_run") == 33675068951,
"evidence must retain the accepted Core 31 tag, digest, source revision, and candidate run",
)
require("Requires 5tratumOS 0.7.12" in manifest, "OS prerequisite must be disclosed")
require("on 5tratumOS, version 0.7.12 or newer is required" 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")
Expand Down
6 changes: 6 additions & 0 deletions tests/test_axebc2_dev_finalizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,13 @@ 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)
# This finalizer belongs to 0.1.11. Restore that exact UI pin/stage
# before exercising its historical acceptance workflow.
fixture = COMPOSE.read_text(encoding="utf-8")
fixture = fixture.replace(
"ghcr.io/willitmod/axebc2-app-umbrel-dev:0.1.12-candidate.3b893173de7b@sha256:5defc8ac3c1d6e188959ed5c0642165e66108701c5ce3881e909c0994c8e3189",
"ghcr.io/willitmod/axebc2-app-umbrel-dev:0.1.11-candidate.ecf6e2c8cfd0@" + APP_DIGEST,
).replace('APP_CHANNEL: "BETA"', 'APP_CHANNEL: "ALPHA"')
fixture = re.sub(r"(ghcr\.io/willitmod/axebc2-app-umbrel-dev:0\.1\.11-candidate\.ecf6e2c8cfd0@sha256:)[0-9a-f]{64}", r"\1APP_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)
Expand Down
146 changes: 146 additions & 0 deletions tests/test_axebc2_umbrel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest

import yaml

ROOT = Path(__file__).resolve().parents[1]
APP = ROOT / "willitmod-dev-bc2"


class UmbrelPackagingTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix="axebc2-umbrel-")
self.root = Path(self.temp.name)
self.data = self.root / "data"
shutil.copytree(APP / "data", self.data)
shutil.copytree(APP / "hooks", self.root / "hooks")
self.env = os.environ.copy()
self.env.update({
"AXEBC2_PLATFORM": "umbrel", "AXEBC2_DATA_DIR": str(self.data),
"AXEBC2_APPDATA_DIR": str(self.root),
"AXEBC2_TEMPLATES_DIR": str(self.data / "templates"),
"AXEBC2_TEST_SKIP_CHOWN": "true", "APP_DATA_DIR": str(self.root),
"APP_PASSWORD": "test-only", "JWT_SECRET": "test-only",
"NETWORK_IP": "10.21.0.0", "APPS_SUBNET": "10.21.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",
"AXEBC2_BUILD_FILE": str(self.root / "does-not-exist.json"),
})

def tearDown(self):
self.temp.cleanup()

def run_init(self, expected=0):
result = subprocess.run(["sh", str(self.root / "hooks/umbrel-init")],
env=self.env, text=True, capture_output=True)
self.assertEqual(result.returncode, expected, result.stderr)
return result

def test_generated_artifacts_are_current(self):
subprocess.run([sys.executable, str(ROOT / "scripts/build-axebc2-umbrel.py"), "--check"], check=True)

def test_5tratumos_recipe_changes_only_the_beta_ui_image_and_stage(self):
source = (APP / "docker-compose.yml").read_text()
current = yaml.safe_load(source)["services"]["app"]["image"]
baseline = source.replace(current,
"ghcr.io/willitmod/axebc2-app-umbrel-dev:0.1.11-candidate.ecf6e2c8cfd0@sha256:23a7962e223da5549eba52697c6f4cfa16ab74cba935c68c48148a4c515302b4"
).replace('APP_CHANNEL: "BETA"', 'APP_CHANNEL: "ALPHA"')
self.assertEqual(hashlib.sha256(baseline.encode()).hexdigest(),
"93ceba92069947f47d650a5fb32205836fe070d83707f36912a2e0e83beb1244")

def test_umbrel_envsubst_and_compose_keep_pins_and_auth_without_os_bind(self):
rendered = subprocess.check_output(["envsubst"], input=(APP / "docker-compose.yml.template").read_text(),
env=self.env, text=True)
self.assertNotIn("/etc/5tratumos", rendered)
config = yaml.safe_load(rendered)
config["services"]["app_proxy"]["image"] = "getumbrel/app-proxy:1.7.0"
path = self.root / "docker-compose.yml"
path.write_text(yaml.safe_dump(config))
result = subprocess.run(["docker", "compose", "-f", str(path), "config", "--format", "json"],
capture_output=True, text=True, env=self.env)
self.assertEqual(result.returncode, 0, result.stderr)
services = json.loads(result.stdout)["services"]
accepted = yaml.safe_load((APP / "docker-compose.yml").read_text())["services"]
for service in ("app", "btc2d", "ckpool", "init"):
self.assertEqual(services[service]["image"], accepted[service]["image"])
self.assertEqual(services["app_proxy"]["environment"]["JWT_SECRET"], "test-only")
self.assertEqual(services["app_proxy"]["environment"]["APP_HOST"], "axebc2-app")
self.assertEqual(services["btc2d"]["depends_on"]["init"]["condition"], "service_completed_successfully")
self.assertEqual(services["init"]["environment"]["AXEBC2_PLATFORM"], "umbrel")
for service in services.values():
for volume in service.get("volumes", []):
if volume["type"] == "bind":
self.assertFalse(volume.get("bind", {}).get("create_host_path", False))
self.assertTrue(Path(volume["source"]).exists(), volume)

def test_fresh_umbrel_init_succeeds_without_5tratumos_metadata(self):
self.run_init()
policy = json.loads((self.data / ".5tratumos-rollback-policy.json").read_text())
self.assertEqual(policy["minimum_base_version"], "0.1.10")
self.assertEqual(policy["minimum_5tratumos_version"], "0.7.12")
self.assertTrue((self.data / "node/bitcoinII.conf").is_file())

def test_missing_umbrel_recipe_identity_fails_before_writes(self):
self.env.pop("AXEBC2_PLATFORM")
self.run_init(78)
self.assertFalse((self.data / ".5tratumos-rollback-policy.json").exists())
self.assertFalse((self.root / "settings.yml").exists())

def test_existing_chain_requires_reindex_and_keeps_data(self):
blocks = self.data / "node/blocks"
blocks.mkdir()
sentinel = blocks / "blk00000.dat"
sentinel.write_bytes(b"preserved chain")
self.run_init()
marker = json.loads((self.data / "node/.core31-full-reindex-required.json").read_text())
self.assertEqual(marker["minimum_core_major"], 31)
self.assertEqual(marker["activation_height"], 57750)
self.assertEqual(sentinel.read_bytes(), b"preserved chain")

def test_malformed_migration_marker_still_rejects_startup(self):
marker = self.data / "node/.core31-full-reindex-complete.json"
marker.write_text('{"migration":"not-accepted"}')
self.run_init(78)
self.assertEqual(marker.read_text(), '{"migration":"not-accepted"}')
self.assertFalse((self.data / "node/bitcoinII.conf").exists())

def test_valid_completion_survives_restart_without_reindex(self):
marker = self.data / "node/.core31-full-reindex-complete.json"
complete = {
"schema": 1, "migration": "bitcoinii-shockwave-core31-full-reindex",
"minimum_core_major": 31, "activation_height": 57750,
"completed_at": "2026-09-04T17:22:22Z", "validated_height": 58444,
"best_block_hash": "0" * 64, "core_version": 310100,
"checkpoint_height": 57752,
"checkpoint_hash": "000000000000000013ceffe797280c57f75a5b9f1d9e70c3503584058c322576",
"validated_chainwork": "0000000000000000000000000000000000000000000000959028194ff1139272",
}
marker.write_text(json.dumps(complete))
(self.data / "node/chainstate").mkdir()
self.run_init()
self.run_init()
self.assertEqual(json.loads(marker.read_text()), complete)
self.assertFalse((self.data / "node/.core31-full-reindex-required.json").exists())

def test_preserved_data_initializer_is_not_executed_on_upgrade(self):
(self.data / "init/init.sh").write_text("#!/bin/sh\nexit 99\n")
self.run_init()
config = self.data / "pool/config/ckpool.conf"
saved = json.loads(config.read_text())
saved["btcaddress"] = "retained-test-payout"
config.write_text(json.dumps(saved))
self.run_init()
self.assertEqual(json.loads(config.read_text())["btcaddress"], "retained-test-payout")


if __name__ == "__main__":
unittest.main()
26 changes: 26 additions & 0 deletions willitmod-dev-bc2/BETA-RELEASE-EVIDENCE.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"image": "ghcr.io/willitmod/axebc2-app-umbrel-dev",
"candidate": "ghcr.io/willitmod/axebc2-app-umbrel-dev:0.1.12-candidate.3b893173de7b",
"digest": "sha256:5defc8ac3c1d6e188959ed5c0642165e66108701c5ce3881e909c0994c8e3189",
"revision": "3b893173de7b43c70bdddac4817a05e35ffa1957",
"version": "0.1.12",
"platforms": [
"linux/amd64",
"linux/arm64"
],
"provenance": true,
"sbom": true,
"candidate_run": 34026015018,
"tested_on": "10.10.10.235",
"scope": "UI-only candidate, separate writable test data, existing Core 31 node via read-only RPC methods",
"browser_verified": {
"version": "0.1.12-dev",
"channel": "BETA",
"legacy_alpha_environment": true,
"release_warning_visible": false,
"node_synchronized": true
},
"application_tests_passed": 50,
"store_tests_passed": 33,
"umbrel_lifecycle_tested": false
}
Loading
Loading