diff --git a/src/tools/diag_impls/identity_health.py b/src/tools/diag_impls/identity_health.py index 984209c..00650ab 100644 --- a/src/tools/diag_impls/identity_health.py +++ b/src/tools/diag_impls/identity_health.py @@ -109,6 +109,16 @@ def diag_identity_health() -> dict: return out # 2. Read cluster identity.json — get the on-chain peerID. + # A DIRECTORY at this path is the Docker bind-mount footgun: a *file* + # bind-mount of an absent identity.json makes dockerd create the source as + # an empty directory, which permanently wedges ipfs-cluster (its init loops + # on `[ -f identity.json ]`). Detect it precisely and report a distinct + # reason so the tree can give a specific "corrupted identity" verdict instead + # of conflating it with a legitimately-absent identity (pre-bootstrap). + if os.path.isdir(CLUSTER_IDENTITY_PATH): + out["pool_member_reason"] = "cluster_identity_is_directory" + out["online_recent_reason"] = "cluster_identity_is_directory" + return out cluster_peer = _read_cluster_peer_id(CLUSTER_IDENTITY_PATH) if not cluster_peer: out["pool_member_reason"] = "missing_cluster_peer_id" diff --git a/tests/test_diag_impls.py b/tests/test_diag_impls.py index 8b4d82a..d96ab9c 100644 --- a/tests/test_diag_impls.py +++ b/tests/test_diag_impls.py @@ -1089,6 +1089,42 @@ def fake_open_router(path, *args, **kwargs): _validate(r, "identity_health") +def test_identity_health_cluster_identity_is_directory(): + """Docker bind-mount footgun: when identity.json is a DIRECTORY (dockerd + auto-created the absent file-bind source as a dir), the diag reports the + distinct `cluster_identity_is_directory` reason instead of the generic + `missing_cluster_peer_id`, so the not-earning tree can give a specific + "cluster identity is a folder" verdict. It must short-circuit before any + chain call.""" + from src.tools.diag_impls import identity_health as mod + from src.tools.chain import clear_cache_for_tests + clear_cache_for_tests() + + def fake_eth_call(*a, **k): + raise AssertionError("eth_call must not run when identity.json is a directory") + + def fake_open_router(path, *args, **kwargs): + from io import StringIO + if path == mod.CONFIG_YAML_PATH: + return StringIO(_FAKE_CONFIG_YAML) + raise FileNotFoundError(path) + + def fake_isdir(path): + return path == mod.CLUSTER_IDENTITY_PATH + + with patch.object(mod, "eth_call", side_effect=fake_eth_call), \ + patch("builtins.open", side_effect=fake_open_router), \ + patch("os.path.isdir", side_effect=fake_isdir): + r = mod.diag_identity_health() + assert r["pool_member"] is None + assert r["pool_member_reason"] == "cluster_identity_is_directory" + assert r["online_recent"] is None + assert r["online_recent_reason"] == "cluster_identity_is_directory" + # Short-circuits before the peerID read + any chain call. + assert "cluster_peer_id" not in r + _validate(r, "identity_health") + + def test_identity_health_rpc_unreachable_marks_both_unknown(): from src.tools.diag_impls import identity_health as mod from src.tools.chain import CallResult, clear_cache_for_tests