pyln-testing: check plugin notifications against any extant notification schemas.

Note that we need a workaround for deprecated APIs where "channel_state_changed" output "null" which violated the schema.

Signed-off-by: Rusty Russell <rusty@rustcorp.com.au>
This commit is contained in:
Rusty Russell
2025-08-17 09:39:35 +09:30
parent 9e7be804c2
commit e43a4a96e7
3 changed files with 70 additions and 2 deletions

View File

@@ -3,6 +3,7 @@ from pyln.testing.db import SqliteDbProvider, PostgresDbProvider
from pyln.testing.utils import NodeFactory, BitcoinD, ElementsD, env, LightningNode, TEST_DEBUG, TEST_NETWORK
from pyln.client import Millisatoshi
from typing import Dict
from pathlib import Path
import json
import jsonschema # type: ignore
@@ -496,6 +497,7 @@ def node_factory(request, directory, test_name, bitcoind, executor, db_provider,
map_node_error(nf.nodes, checkBroken, "had BROKEN messages")
map_node_error(nf.nodes, lambda n: not n.allow_warning and n.daemon.is_in_log(r' WARNING:'), "had warning messages")
map_node_error(nf.nodes, checkReconnect, "had unexpected reconnections")
map_node_error(nf.nodes, checkPluginJSON, "had malformed hooks/notifications")
# On any bad gossip complaints, print out every nodes' gossip_store
if map_node_error(nf.nodes, checkBadGossip, "had bad gossip messages"):
@@ -617,6 +619,57 @@ def checkBroken(node):
return 0
def checkPluginJSON(node):
if node.bad_notifications:
return 0
try:
notificationfiles = os.listdir('doc/schemas/notification')
except FileNotFoundError:
notificationfiles = []
notifications = {}
for fname in notificationfiles:
if fname.endswith('.json'):
base = fname.replace('.json', '')
# Request is 0 and Response is 1
notifications[base] = _load_schema(os.path.join('doc/schemas/notification', fname))
# FIXME: add doc/schemas/hook/
hooks = {}
for f in (Path(node.daemon.lightning_dir) / "plugin-io").iterdir():
# e.g. hook_in-peer_connected-124567-358
io = json.loads(f.read_text())
parts = f.name.split('-')
if parts[0] == 'hook_in':
schema = hooks.get(parts[1])
req = io['result']
direction = 1
elif parts[0] == 'hook_out':
schema = hooks.get(parts[1])
req = io['params']
direction = 0
else:
assert parts[0] == 'notification_out'
schema = notifications.get(parts[1])
# The notification is wrapped in an object of its own name.
req = io['params'][parts[1]]
direction = 1
# Until v26.09, with channel_state_changed.null_scid, that notification will be non-schema compliant.
if f.name.startswith('notification_out-channel_state_changed-') and node.daemon.opts.get('allow-deprecated-apis', True) is True:
continue
if schema is not None:
try:
schema[direction].validate(req)
except jsonschema.exceptions.ValidationError as e:
print(f"Failed validating {f}: {e}")
return 1
return 0
def checkBadReestablish(node):
if node.daemon.is_in_log('Bad reestablish'):
return 1

View File

@@ -789,11 +789,13 @@ class LightningNode(object):
jsonschemas={},
valgrind_plugins=True,
executable=None,
bad_notifications=False,
**kwargs):
self.bitcoin = bitcoind
self.executor = executor
self.may_fail = may_fail
self.may_reconnect = may_reconnect
self.bad_notifications = bad_notifications
self.broken_log = broken_log
self.allow_bad_gossip = allow_bad_gossip
self.allow_warning = allow_warning
@@ -853,6 +855,10 @@ class LightningNode(object):
if self.cln_version >= "v24.11":
self.daemon.opts.update({"autoconnect-seeker-peers": 0})
jsondir = Path(lightning_dir) / "plugin-io"
jsondir.mkdir()
self.daemon.opts['dev-save-plugin-io'] = jsondir
if options is not None:
self.daemon.opts.update(options)
dsn = db.get_dsn()
@@ -1601,6 +1607,7 @@ class NodeFactory(object):
'broken_log',
'allow_warning',
'may_reconnect',
'bad_notifications',
'random_hsm',
'feerates',
'wait_for_bitcoind_sync',