diff --git a/libs/cln-version-manager/clnvm/cln_version_manager.py b/libs/cln-version-manager/clnvm/cln_version_manager.py index c469c4f84..6a118d2a3 100644 --- a/libs/cln-version-manager/clnvm/cln_version_manager.py +++ b/libs/cln-version-manager/clnvm/cln_version_manager.py @@ -4,6 +4,7 @@ import logging import os from pathlib import Path +import re import subprocess import sys import tarfile @@ -43,6 +44,40 @@ class VersionDescriptor: logger = logging.getLogger(__name__) +# Matches tags like ``v25.12``, ``v25.12.``, ``v25.12gl1`` or ``v0.11.2gl2``. +# Group 1 is the dotted numeric base, group 2 the optional greenlight (``glN``) +# revision. +_VERSION_RE = re.compile(r"^v?(\d+(?:\.\d+)*)\.?(?:gl(\d+))?$") + + +def version_sort_key(tag: str) -> Optional[Tuple[Tuple[int, ...], int]]: + """Return a deterministic sort key for a CLN version tag. + + The key is ``(base, glrev)`` where ``base`` is the tuple of numeric + version components and ``glrev`` is the greenlight suffix revision + (``-1`` when there is no ``glN`` suffix, so a plain upstream build sorts + just below its greenlight counterpart). Returns ``None`` for tags that + are not numbered releases, e.g. ``main``, so callers can skip them. + """ + m = _VERSION_RE.match(tag) + if m is None: + return None + base = tuple(int(p) for p in m.group(1).split(".")) + glrev = int(m.group(2)) if m.group(2) is not None else -1 + return base, glrev + + +def version_base(tag: str) -> Optional[Tuple[int, ...]]: + """Return the numeric base version, ignoring any ``glN`` suffix. + + Used to compare against the supported-version bounds, which are + expressed without the greenlight suffix (the signer reports e.g. + ``v25.12``, which must match both ``v25.12`` and ``v25.12gl1``). + """ + key = version_sort_key(tag) + return key[0] if key is not None else None + + def _get_cache_dir() -> Path: cln_cache_dir = os.environ.get("CLNVM_CACHE_DIR") if cln_cache_dir is not None: @@ -250,11 +285,56 @@ def get_descriptor_from_tag(self, tag: str) -> VersionDescriptor: return descriptor + def supported_versions( + self, lowest: str, highest: str + ) -> List[VersionDescriptor]: + """Return the supported versions, sorted ascending. + + A version is supported when its base version (ignoring the ``glN`` + suffix) lies within ``[lowest, highest]`` inclusive. Tags that are + not numbered releases (e.g. ``main``) are dropped. + """ + low = version_base(lowest) + high = version_base(highest) + if low is None or high is None: + raise ValueError( + f"Invalid version bounds: lowest={lowest!r}, highest={highest!r}" + ) + + selected = [] + for d in self.get_versions(): + key = version_sort_key(d.tag) + if key is not None and low <= key[0] <= high: + selected.append((key, d)) + + selected.sort(key=lambda kd: kd[0]) + return [d for _, d in selected] + + def latest_supported(self, lowest: str, highest: str) -> NodeVersion: + """Return the newest supported version within ``[lowest, highest]``. + + Deterministic: versions are ordered by ``(base, glrev)`` so the + greenlight build wins over the plain upstream build of the same base + version. Use this rather than :meth:`latest` so that newer-but- + unsupported releases present in the manifest are never picked up. + """ + supported = self.supported_versions(lowest, highest) + if not supported: + raise ValueError( + f"No CLN version available in range [{lowest}, {highest}]" + ) + return self.get(supported[-1]) + def latest(self) -> NodeVersion: - vs = [d.tag for d in self.get_versions()] - latest = max(vs) - descriptor = self.get_descriptor_from_tag(latest) - return self.get(descriptor) + candidates = [] + for d in self.get_versions(): + key = version_sort_key(d.tag) + if key is not None: + candidates.append((key, d)) + if not candidates: + raise ValueError("No numbered CLN version available in the manifest") + _, latest = max(candidates, key=lambda kd: kd[0]) + return self.get(latest) def get(self, cln_version: VersionDescriptor, force: bool = False) -> NodeVersion: """ diff --git a/libs/cln-version-manager/tests/test_version_manager.py b/libs/cln-version-manager/tests/test_version_manager.py index be4dcf08b..8f2b750b8 100644 --- a/libs/cln-version-manager/tests/test_version_manager.py +++ b/libs/cln-version-manager/tests/test_version_manager.py @@ -5,7 +5,30 @@ import requests -from clnvm.cln_version_manager import ClnVersionManager, VersionDescriptor +from clnvm.cln_version_manager import ( + ClnVersionManager, + VersionDescriptor, + version_base, + version_sort_key, +) + + +def _descriptor(tag: str) -> VersionDescriptor: + return VersionDescriptor(tag=tag, url="", checksum=tag) + + +# Mirrors the kind of tags found in the production manifest, deliberately out +# of order. +_MANIFEST_TAGS = [ + "main", + "v0.11.2gl2", + "v23.08.", + "v23.08gl1", + "v24.11gl1", + "v25.12.", + "v25.12gl1", + "v26.06gl1", +] def get_tmp_dir(name: str) -> str: @@ -32,3 +55,55 @@ def test_download_cln_version() -> None: with mock.patch("requests.get") as request_mock: vm_test.get_all() assert not request_mock.get.called + + +def test_version_sort_key() -> None: + # ``main`` and other non-numbered tags are not orderable. + assert version_sort_key("main") is None + + # The glN suffix is captured separately, and absent suffixes sort below + # their greenlight counterpart of the same base. + assert version_sort_key("v25.12.") == ((25, 12), -1) + assert version_sort_key("v25.12gl1") == ((25, 12), 1) + assert version_sort_key("v0.11.2gl2") == ((0, 11, 2), 2) + assert version_sort_key("v25.12.") < version_sort_key("v25.12gl1") + assert version_sort_key("v25.12gl1") < version_sort_key("v26.06gl1") + + +def test_version_base_ignores_suffix() -> None: + # Base comparison ignores the glN suffix, so the signer reporting + # ``v25.12`` matches both the plain and greenlight builds. + assert version_base("v25.12") == version_base("v25.12gl1") == (25, 12) + assert version_base("v25.12.") == (25, 12) + assert version_base("main") is None + + +def test_supported_versions_filters_and_sorts() -> None: + vm = ClnVersionManager( + cln_versions=[_descriptor(t) for t in _MANIFEST_TAGS] + ) + + supported = vm.supported_versions("v23.08", "v25.12") + tags = [d.tag for d in supported] + + # ``main`` dropped (not numbered), ``v0.11.2gl2`` below the lower bound, + # ``v26.06gl1`` above the signer-supported upper bound. Result is sorted + # ascending with the greenlight build after the plain build of the same + # base. + assert tags == [ + "v23.08.", + "v23.08gl1", + "v24.11gl1", + "v25.12.", + "v25.12gl1", + ] + + +def test_supported_versions_latest_is_greenlight_build() -> None: + vm = ClnVersionManager( + cln_versions=[_descriptor(t) for t in _MANIFEST_TAGS] + ) + + # The newest supported version is the greenlight build at the upper + # bound, never the newer-but-unsupported v26.06gl1. + assert vm.supported_versions("v23.08", "v25.12")[-1].tag == "v25.12gl1" diff --git a/libs/gl-plugin/src/node/mod.rs b/libs/gl-plugin/src/node/mod.rs index b6e68c465..e5745bdf0 100644 --- a/libs/gl-plugin/src/node/mod.rs +++ b/libs/gl-plugin/src/node/mod.rs @@ -296,10 +296,12 @@ impl Node for PluginNodeServer { // In case the client did not specify an LSP to work with, // let's enumerate them, and select the best option ourselves. - let lsps = self.get_lsps_offers(&mut rpc).await.map_err(|_e| { + let mut lsps = self.get_lsps_offers(&mut rpc).await.map_err(|_e| { Status::not_found("Could not retrieve LSPS peers for invoice negotiation.") })?; + lsps.sort_by_key(|l| l.node_id.clone()); + if lsps.len() < 1 { return Err(Status::not_found( "Could not find an LSP peer to negotiate the LSPS2 channel for this invoice.", diff --git a/libs/gl-testing/gltesting/fixtures.py b/libs/gl-testing/gltesting/fixtures.py index 655848d2e..549185e5d 100644 --- a/libs/gl-testing/gltesting/fixtures.py +++ b/libs/gl-testing/gltesting/fixtures.py @@ -27,6 +27,7 @@ from gltesting.grpcweb import GrpcWebProxy, NodeHandler from gltesting.lnurl_server import LnurlServer from clnvm import ClnVersionManager +import glclient logging.basicConfig(level=logging.DEBUG, stream=sys.stdout) @@ -36,6 +37,18 @@ logger = logging.getLogger(__name__) +# Bounds for selecting the default CLN version used by the test suite. +# +# The lowest is pinned explicitly: bump it as we drop support for old +# releases. The highest is whatever the gl-client signer (libhsmd) supports, +# so a newer release that has landed in the manifest but for which we are +# still building client/signer support (e.g. v26.06gl1) is never picked up +# automatically. The ``glN`` suffix is ignored when comparing, so the signer +# reporting ``v25.12`` matches ``v25.12gl1``. +LOWEST_SUPPORTED_VERSION = "v23.08" +HIGHEST_SUPPORTED_VERSION = glclient.__version__ + + @pytest.fixture(autouse=True) def paths(): """A fixture to ensure that we have all CLN versions and that @@ -47,14 +60,15 @@ def paths(): """ vm = ClnVersionManager() - versions = vm.get_versions() # Should be a no-op after the first run vm.get_all() - latest = [v for v in versions if "gl" in v.tag][-1] + latest = vm.latest_supported( + LOWEST_SUPPORTED_VERSION, HIGHEST_SUPPORTED_VERSION + ) - os.environ["PATH"] += f":{vm.get_target_path(latest) / 'usr' / 'local' / 'bin'}" + os.environ["PATH"] += f":{latest.bin_path}" yield @@ -187,7 +201,9 @@ def cln_path() -> Path: https://en.wikipedia.org/wiki/Elephant_in_Cairo """ manager = ClnVersionManager() - v = manager.latest() + v = manager.latest_supported( + LOWEST_SUPPORTED_VERSION, HIGHEST_SUPPORTED_VERSION + ) os.environ["PATH"] += f":{v.bin_path}" return v.bin_path