Skip to content

Commit 4cdd54a

Browse files
cdeckerclaude
andcommitted
clnvm: Make CLN version selection deterministic and bounded
The test harness picked the default CLN node version with a lexicographic `max()` over the manifest tags (and a `[... "gl" ...][-1]` in the `paths` fixture). As soon as a new release lands in the shared manifest.json it becomes the default for the whole suite, even if the client and signer do not support it yet. That is how `v26.06gl1` started being used before support was built in. Make selection deterministic and explicitly bounded: - Add `version_sort_key()`/`version_base()` parsing the `vX.Y[.Z][glN]` tags into numeric, ordered keys. The `glN` suffix is a separate component, so ordering is numeric (not lexicographic) and the base version can be compared independently of the greenlight suffix. - Add `ClnVersionManager.supported_versions(lowest, highest)` and `latest_supported(lowest, highest)`, filtering to base versions within `[lowest, highest]` inclusive and dropping non-numbered tags (`main`). - Make the existing `latest()` deterministic too. - In the gl-testing fixtures, pin `LOWEST_SUPPORTED_VERSION` explicitly and derive `HIGHEST_SUPPORTED_VERSION` from `glclient.__version__`, i.e. what the signer (libhsmd) actually supports. The suffix is ignored when comparing, so the signer reporting `v25.12` matches `v25.12gl1` but excludes `v26.06gl1`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9ccf248 commit 4cdd54a

3 files changed

Lines changed: 180 additions & 9 deletions

File tree

libs/cln-version-manager/clnvm/cln_version_manager.py

Lines changed: 84 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import logging
55
import os
66
from pathlib import Path
7+
import re
78
import subprocess
89
import sys
910
import tarfile
@@ -43,6 +44,40 @@ class VersionDescriptor:
4344
logger = logging.getLogger(__name__)
4445

4546

47+
# Matches tags like ``v25.12``, ``v25.12.``, ``v25.12gl1`` or ``v0.11.2gl2``.
48+
# Group 1 is the dotted numeric base, group 2 the optional greenlight (``glN``)
49+
# revision.
50+
_VERSION_RE = re.compile(r"^v?(\d+(?:\.\d+)*)\.?(?:gl(\d+))?$")
51+
52+
53+
def version_sort_key(tag: str) -> Optional[Tuple[Tuple[int, ...], int]]:
54+
"""Return a deterministic sort key for a CLN version tag.
55+
56+
The key is ``(base, glrev)`` where ``base`` is the tuple of numeric
57+
version components and ``glrev`` is the greenlight suffix revision
58+
(``-1`` when there is no ``glN`` suffix, so a plain upstream build sorts
59+
just below its greenlight counterpart). Returns ``None`` for tags that
60+
are not numbered releases, e.g. ``main``, so callers can skip them.
61+
"""
62+
m = _VERSION_RE.match(tag)
63+
if m is None:
64+
return None
65+
base = tuple(int(p) for p in m.group(1).split("."))
66+
glrev = int(m.group(2)) if m.group(2) is not None else -1
67+
return base, glrev
68+
69+
70+
def version_base(tag: str) -> Optional[Tuple[int, ...]]:
71+
"""Return the numeric base version, ignoring any ``glN`` suffix.
72+
73+
Used to compare against the supported-version bounds, which are
74+
expressed without the greenlight suffix (the signer reports e.g.
75+
``v25.12``, which must match both ``v25.12`` and ``v25.12gl1``).
76+
"""
77+
key = version_sort_key(tag)
78+
return key[0] if key is not None else None
79+
80+
4681
def _get_cache_dir() -> Path:
4782
cln_cache_dir = os.environ.get("CLNVM_CACHE_DIR")
4883
if cln_cache_dir is not None:
@@ -250,11 +285,56 @@ def get_descriptor_from_tag(self, tag: str) -> VersionDescriptor:
250285

251286
return descriptor
252287

288+
def supported_versions(
289+
self, lowest: str, highest: str
290+
) -> List[VersionDescriptor]:
291+
"""Return the supported versions, sorted ascending.
292+
293+
A version is supported when its base version (ignoring the ``glN``
294+
suffix) lies within ``[lowest, highest]`` inclusive. Tags that are
295+
not numbered releases (e.g. ``main``) are dropped.
296+
"""
297+
low = version_base(lowest)
298+
high = version_base(highest)
299+
if low is None or high is None:
300+
raise ValueError(
301+
f"Invalid version bounds: lowest={lowest!r}, highest={highest!r}"
302+
)
303+
304+
selected = []
305+
for d in self.get_versions():
306+
key = version_sort_key(d.tag)
307+
if key is not None and low <= key[0] <= high:
308+
selected.append((key, d))
309+
310+
selected.sort(key=lambda kd: kd[0])
311+
return [d for _, d in selected]
312+
313+
def latest_supported(self, lowest: str, highest: str) -> NodeVersion:
314+
"""Return the newest supported version within ``[lowest, highest]``.
315+
316+
Deterministic: versions are ordered by ``(base, glrev)`` so the
317+
greenlight build wins over the plain upstream build of the same base
318+
version. Use this rather than :meth:`latest` so that newer-but-
319+
unsupported releases present in the manifest are never picked up.
320+
"""
321+
supported = self.supported_versions(lowest, highest)
322+
if not supported:
323+
raise ValueError(
324+
f"No CLN version available in range [{lowest}, {highest}]"
325+
)
326+
return self.get(supported[-1])
327+
253328
def latest(self) -> NodeVersion:
254-
vs = [d.tag for d in self.get_versions()]
255-
latest = max(vs)
256-
descriptor = self.get_descriptor_from_tag(latest)
257-
return self.get(descriptor)
329+
candidates = []
330+
for d in self.get_versions():
331+
key = version_sort_key(d.tag)
332+
if key is not None:
333+
candidates.append((key, d))
334+
if not candidates:
335+
raise ValueError("No numbered CLN version available in the manifest")
336+
_, latest = max(candidates, key=lambda kd: kd[0])
337+
return self.get(latest)
258338

259339
def get(self, cln_version: VersionDescriptor, force: bool = False) -> NodeVersion:
260340
"""

libs/cln-version-manager/tests/test_version_manager.py

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,30 @@
55

66
import requests
77

8-
from clnvm.cln_version_manager import ClnVersionManager, VersionDescriptor
8+
from clnvm.cln_version_manager import (
9+
ClnVersionManager,
10+
VersionDescriptor,
11+
version_base,
12+
version_sort_key,
13+
)
14+
15+
16+
def _descriptor(tag: str) -> VersionDescriptor:
17+
return VersionDescriptor(tag=tag, url="", checksum=tag)
18+
19+
20+
# Mirrors the kind of tags found in the production manifest, deliberately out
21+
# of order.
22+
_MANIFEST_TAGS = [
23+
"main",
24+
"v0.11.2gl2",
25+
"v23.08.",
26+
"v23.08gl1",
27+
"v24.11gl1",
28+
"v25.12.",
29+
"v25.12gl1",
30+
"v26.06gl1",
31+
]
932

1033

1134
def get_tmp_dir(name: str) -> str:
@@ -32,3 +55,55 @@ def test_download_cln_version() -> None:
3255
with mock.patch("requests.get") as request_mock:
3356
vm_test.get_all()
3457
assert not request_mock.get.called
58+
59+
60+
def test_version_sort_key() -> None:
61+
# ``main`` and other non-numbered tags are not orderable.
62+
assert version_sort_key("main") is None
63+
64+
# The glN suffix is captured separately, and absent suffixes sort below
65+
# their greenlight counterpart of the same base.
66+
assert version_sort_key("v25.12.") == ((25, 12), -1)
67+
assert version_sort_key("v25.12gl1") == ((25, 12), 1)
68+
assert version_sort_key("v0.11.2gl2") == ((0, 11, 2), 2)
69+
assert version_sort_key("v25.12.") < version_sort_key("v25.12gl1")
70+
assert version_sort_key("v25.12gl1") < version_sort_key("v26.06gl1")
71+
72+
73+
def test_version_base_ignores_suffix() -> None:
74+
# Base comparison ignores the glN suffix, so the signer reporting
75+
# ``v25.12`` matches both the plain and greenlight builds.
76+
assert version_base("v25.12") == version_base("v25.12gl1") == (25, 12)
77+
assert version_base("v25.12.") == (25, 12)
78+
assert version_base("main") is None
79+
80+
81+
def test_supported_versions_filters_and_sorts() -> None:
82+
vm = ClnVersionManager(
83+
cln_versions=[_descriptor(t) for t in _MANIFEST_TAGS]
84+
)
85+
86+
supported = vm.supported_versions("v23.08", "v25.12")
87+
tags = [d.tag for d in supported]
88+
89+
# ``main`` dropped (not numbered), ``v0.11.2gl2`` below the lower bound,
90+
# ``v26.06gl1`` above the signer-supported upper bound. Result is sorted
91+
# ascending with the greenlight build after the plain build of the same
92+
# base.
93+
assert tags == [
94+
"v23.08.",
95+
"v23.08gl1",
96+
"v24.11gl1",
97+
"v25.12.",
98+
"v25.12gl1",
99+
]
100+
101+
102+
def test_supported_versions_latest_is_greenlight_build() -> None:
103+
vm = ClnVersionManager(
104+
cln_versions=[_descriptor(t) for t in _MANIFEST_TAGS]
105+
)
106+
107+
# The newest supported version is the greenlight build at the upper
108+
# bound, never the newer-but-unsupported v26.06gl1.
109+
assert vm.supported_versions("v23.08", "v25.12")[-1].tag == "v25.12gl1"

libs/gl-testing/gltesting/fixtures.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from gltesting.grpcweb import GrpcWebProxy, NodeHandler
2828
from gltesting.lnurl_server import LnurlServer
2929
from clnvm import ClnVersionManager
30+
import glclient
3031

3132

3233
logging.basicConfig(level=logging.DEBUG, stream=sys.stdout)
@@ -36,6 +37,18 @@
3637
logger = logging.getLogger(__name__)
3738

3839

40+
# Bounds for selecting the default CLN version used by the test suite.
41+
#
42+
# The lowest is pinned explicitly: bump it as we drop support for old
43+
# releases. The highest is whatever the gl-client signer (libhsmd) supports,
44+
# so a newer release that has landed in the manifest but for which we are
45+
# still building client/signer support (e.g. v26.06gl1) is never picked up
46+
# automatically. The ``glN`` suffix is ignored when comparing, so the signer
47+
# reporting ``v25.12`` matches ``v25.12gl1``.
48+
LOWEST_SUPPORTED_VERSION = "v23.08"
49+
HIGHEST_SUPPORTED_VERSION = glclient.__version__
50+
51+
3952
@pytest.fixture(autouse=True)
4053
def paths():
4154
"""A fixture to ensure that we have all CLN versions and that
@@ -47,14 +60,15 @@ def paths():
4760
4861
"""
4962
vm = ClnVersionManager()
50-
versions = vm.get_versions()
5163

5264
# Should be a no-op after the first run
5365
vm.get_all()
5466

55-
latest = [v for v in versions if "gl" in v.tag][-1]
67+
latest = vm.latest_supported(
68+
LOWEST_SUPPORTED_VERSION, HIGHEST_SUPPORTED_VERSION
69+
)
5670

57-
os.environ["PATH"] += f":{vm.get_target_path(latest) / 'usr' / 'local' / 'bin'}"
71+
os.environ["PATH"] += f":{latest.bin_path}"
5872

5973
yield
6074

@@ -187,7 +201,9 @@ def cln_path() -> Path:
187201
https://en.wikipedia.org/wiki/Elephant_in_Cairo
188202
"""
189203
manager = ClnVersionManager()
190-
v = manager.latest()
204+
v = manager.latest_supported(
205+
LOWEST_SUPPORTED_VERSION, HIGHEST_SUPPORTED_VERSION
206+
)
191207
os.environ["PATH"] += f":{v.bin_path}"
192208
return v.bin_path
193209

0 commit comments

Comments
 (0)