diff --git a/.github/workflows/validate-axebc2-core31-dev.yml b/.github/workflows/validate-axebc2-core31-dev.yml new file mode 100644 index 0000000..ba030b5 --- /dev/null +++ b/.github/workflows/validate-axebc2-core31-dev.yml @@ -0,0 +1,48 @@ +name: Validate AxeBC2 Core 31 DEV metadata + +on: + pull_request: + paths: + - "willitmod-dev-bc2/**" + - "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: + branches: [main] + paths: + - "willitmod-dev-bc2/**" + - "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" + +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 python3-yaml + + - name: Validate metadata and migration initialization + run: | + bash -n scripts/finalize-axebc2-0.1.10-dev.sh + 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..e8ef5c5 --- /dev/null +++ b/scripts/axebc2_release_state.py @@ -0,0 +1,56 @@ +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" + +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") + +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/finalize-axebc2-0.1.10-dev.sh b/scripts/finalize-axebc2-0.1.10-dev.sh new file mode 100755 index 0000000..ca9bab6 --- /dev/null +++ b/scripts/finalize-axebc2-0.1.10-dev.sh @@ -0,0 +1,90 @@ +#!/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}" +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" +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" +[[ "$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" +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"; } +trap cleanup EXIT +printf '{"auths":{}}\n' >"$anon_config/config.json" + +resolve_tag() { + local ref="$1" expected="$2" path repository tag token headers resolved + [[ "$ref" == "$app_tag" || "$ref" == "$core_tag" ]] || fail "not an approved candidate tag: $ref" + 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 '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" +} +verify_index() { + local ref="$1" digest="$2" manifest + 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" --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" + +[[ "$(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|$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" +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" "$core_revision" "$os_version" "$os_bundle_sha256" <<'PY' +import json,sys +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":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\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 new file mode 100644 index 0000000..71a9811 --- /dev/null +++ b/scripts/validate-axebc2-core31-dev.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +from pathlib import Path +import importlib.util +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import unittest +import argparse +from axebc2_release_state import validate as validate_release_state, validate_rendered_binds + + +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") +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") +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") +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", +) +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") + +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" + 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) + .replace("/etc/5tratumos/build.json", str(build_metadata)), + 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) +with open(sys.argv[2], 'w', encoding='utf-8') as handle: + json.dump(config, handle) +""" + 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( + { + "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["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", + "Core must wait for successful init completion", + ) + 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() + +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/fixtures/5tratumos_contract_4f979cb.py b/tests/fixtures/5tratumos_contract_4f979cb.py new file mode 100644 index 0000000..831de59 --- /dev/null +++ b/tests/fixtures/5tratumos_contract_4f979cb.py @@ -0,0 +1,123 @@ +"""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} + + +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 new file mode 100644 index 0000000..c3b8ae7 --- /dev/null +++ b/tests/test_axebc2_core31_init.py @@ -0,0 +1,205 @@ +import json +import os +from pathlib import Path +import shutil +import stat +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.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) + 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", + } + ) + 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() + 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.12") + 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()), []) + + 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.12"}), 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_dev_finalizer.py b/tests/test_axebc2_dev_finalizer.py new file mode 100644 index 0000000..ae0544b --- /dev/null +++ b/tests/test_axebc2_dev_finalizer.py @@ -0,0 +1,113 @@ +import json +import os +import re +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-rc.cdf44542dde2" +OS_BUNDLE_SHA256 = "11a35e68ab169eb0446485992a57b33fae018a92020b7d86bbf9a005571377af" + +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) + 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" + self.fake.write_text("""#!/bin/sh +set -eu +printf '%s\\n' "$*" >>"$FAKE_DOCKER_LOG" +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" ;; + 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 + 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) + 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, curl_mode="correct"): + 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): + 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["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) + self.assertNotIn("buildx", calls) + 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"): + 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) + 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/tests/test_axebc2_platform_integration.py b/tests/test_axebc2_platform_integration.py new file mode 100644 index 0000000..a359215 --- /dev/null +++ b/tests/test_axebc2_platform_integration.py @@ -0,0 +1,128 @@ +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_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(): + 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.12"}), 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/tests/test_axebc2_release_state.py b/tests/test_axebc2_release_state.py new file mode 100644 index 0000000..d30c05c --- /dev/null +++ b/tests/test_axebc2_release_state.py @@ -0,0 +1,32 @@ +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, validate_rendered_binds + +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") + 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") + 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) diff --git a/willitmod-dev-bc2/CORE31-DEV-RELEASE.md b/willitmod-dev-bc2/CORE31-DEV-RELEASE.md new file mode 100644 index 0000000..a52405a --- /dev/null +++ b/willitmod-dev-bc2/CORE31-DEV-RELEASE.md @@ -0,0 +1,81 @@ +# 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 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 +candidate is fixed to `0.1.10-candidate.6e4ef58218e8` from source revision +`6e4ef58218e8cd5a4d1113196f9872a7f501f52e`. The Core candidate is fixed to +`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 +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 +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. + +## 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 new file mode 100644 index 0000000..8aa75a6 --- /dev/null +++ b/willitmod-dev-bc2/DEV-ACCEPTANCE-EVIDENCE.json @@ -0,0 +1,49 @@ +{ + "schema": 1, + "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", + "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": "11a35e68ab169eb0446485992a57b33fae018a92020b7d86bbf9a005571377af", + "tested_on": "10.10.10.235", + "tested_at": "2026-09-04T13:57:46Z", + "acceptance": { + "observed_at": "2026-09-04T13:57:46Z", + "chain": "main", + "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": "00000000000000000000000000000000000000000000fb0eacdb04473f61a89b", + "ibd": false, + "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": 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 + } +} diff --git a/willitmod-dev-bc2/data/init/init.sh b/willitmod-dev-bc2/data/init/init.sh new file mode 100644 index 0000000..4e9cc5a --- /dev/null +++ b/willitmod-dev-bc2/data/init/init.sh @@ -0,0 +1,208 @@ +#!/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.12" +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 + # 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() { + 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/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/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..4b4f5f6 100644 --- a/willitmod-dev-bc2/docker-compose.yml +++ b/willitmod-dev-bc2/docker-compose.yml @@ -10,12 +10,37 @@ 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 + 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: + JWT_SECRET: "${JWT_SECRET}" APPS_SUBNET: "${NETWORK_IP}/16" RPC_USER: "btc2" RPC_PASSWORD: "${APP_PASSWORD}" @@ -26,88 +51,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-rc.cdf44542dde2@sha256:8875917ece57668fe9925d40a256ce8d429a3071511bb555d4ace1fa4370afc6 user: "1000:1000" restart: unless-stopped stop_grace_period: 15m30s @@ -115,10 +62,14 @@ 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 + image: ghcr.io/willitmod/docker-ckpool-solo:590fb2a@sha256:8a9a7f10c8138d0f55533132ee7710a06715a42a49f75efb39be3350ada4fa6e user: "1000:1000" restart: on-failure depends_on: @@ -129,8 +80,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 @@ -150,7 +110,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-candidate.6e4ef58218e8@sha256:b7ba2df2f48389d145ad18a927b099f32b5aa7708a0ea617a1b04e25c8e7f961 user: "1000:1000" restart: on-failure stop_grace_period: 30s @@ -166,14 +126,19 @@ 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" 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-rc.cdf44542dde2@sha256:8875917ece57668fe9925d40a256ce8d429a3071511bb555d4ace1fa4370afc6" + 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..24fccc5 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.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. @@ -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.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 + peer connections only: no public P2P port or NAT-PMP mapping is enabled. widgets: - id: "sync" type: "text-with-progress"