taler-deployment

Deployment scripts and configuration files
Log | Files | Refs | README

commit 3fcb5a00755f0158564bcb87053711692be037cd
parent c7145788a98a0cd751681de93755fc41151dfd21
Author: Florian Dold <florian@dold.me>
Date:   Tue, 25 Nov 2025 21:01:41 +0100

fix version comparison (via Gemini 3 Pro), add testing

Diffstat:
Mpackaging/ng/taler-pkg | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Apackaging/ng/testing/test-debian-trixie | 20++++++++++++++++++++
Dpackaging/ng/util/__init__.py | 226-------------------------------------------------------------------------------
Apackaging/ng/util/vercomp.py | 179+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 265 insertions(+), 240 deletions(-)

diff --git a/packaging/ng/taler-pkg b/packaging/ng/taler-pkg @@ -16,7 +16,7 @@ file = Path(__file__).resolve() parent, root = file.parent, file.parents[1] sys.path.append(str(root)) -from util import Dpkg +from util import vercomp mydir = os.path.dirname(os.path.realpath(__file__)) @@ -234,18 +234,6 @@ def show_order(cfg): print("build order:", buildorder) -def show_published(cfg): - distro = cfg.distro - vendor, codename = distro.split("-", 1) - listfmt = "${package}_${version}_${architecture}.${$type}\n" - subprocess.run( - [ - "ssh", - f"taler-packaging@{host}", - f"reprepro -b /home/taler-packaging/www/apt/{vendor}/ --list-format '{listfmt}' list {codename}", - ], - check=True, - ) def promote(cfg): @@ -285,6 +273,59 @@ def promote(cfg): check=True, ) +def show_published(cfg): + distro = cfg.distro + vendor, codename = distro.split("-", 1) + listfmt = "${package}_${version}_${architecture}.${$type}\n" + subprocess.run( + [ + "ssh", + f"taler-packaging@{host}", + f"reprepro -b /home/taler-packaging/www/apt/{vendor}/ --list-format '{listfmt}' list {codename}", + ], + check=True, + ) + + +def test(cfg): + distro = cfg.distro + vendor, codename = distro.split("-", 1) + image_tag = f"localhost/taler-packaging-{distro}:latest" + dockerfile = f"distros/{distro}.Dockerfile" + cachedir = Path(f"cache").absolute() + print("building base image") + subprocess.run( + [ + "podman", + "build", + "-v", + f"{cachedir}/{distro}/apt-archives:/var/cache/apt/archives:z", + "-v", + f"{cachedir}/{distro}/apt-lists:/var/lib/apt/lists:z", + "-t", + image_tag, + "-f", + dockerfile, + ], + check=True, + ) + print("running test") + cmd = [ + "podman", + "run", + "-it", + "--entrypoint=/bin/bash", + "--security-opt", + "label=disable", + "--mount", + f"type=bind,source={mydir}/testing/test-{distro},target=/runtest,readonly", + image_tag, + "/runtest", + ] + subprocess.run( + cmd, + check=True, + ) def publish(cfg): distro = cfg.distro @@ -329,7 +370,7 @@ def publish(cfg): pkg2, ver2, *rest2 = srvdeb.removesuffix(".deb").split("_") if pkg1 != pkg2: continue - if Dpkg.compare_versions(ver1, ver2) <= 0: + if vercomp.compare_versions(ver1, ver2) <= 0: fresh = False server_deb = srvdeb break @@ -503,6 +544,17 @@ def main(): "--dry", help="Dry run", action="store_true", default=False ) + parser_test = subparsers.add_parser("test", help="Test packages for distro.") + parser_test.set_defaults(func=test) + parser_test.add_argument("distro") + parser_test.add_argument( + "--arch", + help="Architecture(s) to test packages for", + action="store", + dest="arch", + default=None, + ) + # subcommand show-latest parser_show_latest = subparsers.add_parser( diff --git a/packaging/ng/testing/test-debian-trixie b/packaging/ng/testing/test-debian-trixie @@ -0,0 +1,20 @@ +#!/bin/bash + +set -eu + +wget -O /etc/apt/keyrings/taler-systems.gpg https://taler.net/taler-systems.gpg + +cat >/etc/apt/sources.list.d/taler.sources <<EOF +Architectures: amd64 +Components: main +X-Repolib-Name: Taler +Signed-By: /etc/apt/keyrings/taler-systems.gpg +Suites: trixie-testing +Types: deb +URIs: https://deb.taler.net/apt/debian +EOF + +apt-get update +apt-get upgrade + +apt-get install taler-exchange taler-merchant donau taler-harness taler-wallet-cli diff --git a/packaging/ng/util/__init__.py b/packaging/ng/util/__init__.py @@ -1,226 +0,0 @@ -# Class "Dpkg" adapted in modified form from python-dpkg: -# https://github.com/TheClimateCorporation/python-dpkg/blob/master/pydpkg/__init__.py -# -# License: -# -# Copyright [2017] The Climate Corporation (https://climate.com) -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -class Dpkg: - @staticmethod - def get_upstream(version_str): - """Given a version string that could potentially contain both an upstream - revision and a debian revision, return a tuple of both. If there is no - debian revision, return 0 as the second tuple element.""" - try: - d_index = version_str.rindex("-") - except ValueError: - # no hyphens means no debian version, also valid. - return version_str, "0" - return version_str[0:d_index], version_str[d_index + 1 :] - - @staticmethod - def get_epoch(version_str): - """Parse the epoch out of a package version string. - Return (epoch, version); epoch is zero if not found.""" - try: - # there could be more than one colon, - # but we only care about the first - e_index = version_str.index(":") - except ValueError: - # no colons means no epoch; that's valid, man - return 0, version_str - try: - epoch = int(version_str[0:e_index]) - except ValueError: - raise Exception( - "Corrupt dpkg version %s: epochs can only be ints, and " - "epochless versions cannot use the colon character." % version_str - ) - return epoch, version_str[e_index + 1 :] - - @staticmethod - def split_full_version(version_str): - """Split a full version string into epoch, upstream version and - debian revision.""" - epoch, full_ver = Dpkg.get_epoch(version_str) - upstream_rev, debian_rev = Dpkg.get_upstream(full_ver) - return epoch, upstream_rev, debian_rev - - @staticmethod - def get_alphas(revision_str): - """Return a tuple of the first non-digit characters of a revision (which - may be empty) and the remaining characters.""" - # get the index of the first digit - for i, char in enumerate(revision_str): - if char.isdigit(): - if i == 0: - return "", revision_str - return revision_str[0:i], revision_str[i:] - # string is entirely alphas - return revision_str, "" - - @staticmethod - def get_digits(revision_str): - """Return a tuple of the first integer characters of a revision (which - may be empty) and the remains.""" - # If the string is empty, return (0,'') - if not revision_str: - return 0, "" - # get the index of the first non-digit - for i, char in enumerate(revision_str): - if not char.isdigit(): - if i == 0: - return 0, revision_str - return int(revision_str[0:i]), revision_str[i:] - # string is entirely digits - return int(revision_str), "" - - @staticmethod - def listify(revision_str): - """Split a revision string into a list of alternating between strings and - numbers, padded on either end to always be "str, int, str, int..." and - always be of even length. This allows us to trivially implement the - comparison algorithm described at - http://debian.org/doc/debian-policy/ch-controlfields.html#s-f-Version - """ - result = [] - while revision_str: - rev_1, remains = Dpkg.get_alphas(revision_str) - rev_2, remains = Dpkg.get_digits(remains) - result.extend([rev_1, rev_2]) - revision_str = remains - return result - - @staticmethod - def dstringcmp(a, b): - """debian package version string section lexical sort algorithm - - "The lexical comparison is a comparison of ASCII values modified so - that all the letters sort earlier than all the non-letters and so that - a tilde sorts before anything, even the end of a part." - """ - - if a == b: - return 0 - try: - for i, char in enumerate(a): - if char == b[i]: - continue - # "a tilde sorts before anything, even the end of a part" - # (emptyness) - if char == "~": - return -1 - if b[i] == "~": - return 1 - # "all the letters sort earlier than all the non-letters" - if char.isalpha() and not b[i].isalpha(): - return -1 - if not char.isalpha() and b[i].isalpha(): - return 1 - # otherwise lexical sort - if ord(char) > ord(b[i]): - return 1 - if ord(char) < ord(b[i]): - return -1 - except IndexError: - # a is longer than b but otherwise equal, hence greater - # ...except for goddamn tildes - if char == "~": - return -1 - return 1 - # if we get here, a is shorter than b but otherwise equal, hence lesser - # ...except for goddamn tildes - if b[len(a)] == "~": - return 1 - return -1 - - @staticmethod - def split_full_version(version_str): - """Split a full version string into epoch, upstream version and - debian revision.""" - epoch, full_ver = Dpkg.get_epoch(version_str) - upstream_rev, debian_rev = Dpkg.get_upstream(full_ver) - return epoch, upstream_rev, debian_rev - - @staticmethod - def compare_revision_strings(rev1, rev2): - """Compare two debian revision strings as described at - https://www.debian.org/doc/debian-policy/ch-controlfields.html#s-f-Version - """ - if rev1 == rev2: - return 0 - # listify pads results so that we will always be comparing ints to ints - # and strings to strings (at least until we fall off the end of a list) - list1 = Dpkg.listify(rev1) - list2 = Dpkg.listify(rev2) - if list1 == list2: - return 0 - try: - for i, item in enumerate(list1): - # just in case - if not isinstance(item, list2[i].__class__): - raise DpkgVersionError( - 'Cannot compare %s to %s, something has gone horribly ' - 'awry.' % (item, list2[i])) - # if the items are equal, next - if item == list2[i]: - continue - # numeric comparison - if isinstance(item, int): - if item > list2[i]: - return 1 - if item < list2[i]: - return -1 - else: - # string comparison - return Dpkg.dstringcmp(item, list2[i]) - except IndexError: - # rev1 is longer than rev2 but otherwise equal, hence greater - return 1 - # rev1 is shorter than rev2 but otherwise equal, hence lesser - return -1 - - @staticmethod - def compare_versions(ver1, ver2): - """Function to compare two Debian package version strings, - suitable for passing to list.sort() and friends.""" - if ver1 == ver2: - return 0 - - # note the string conversion: the debian policy here explicitly - # specifies ASCII string comparisons, so if you are mad enough to - # actually cram unicode characters into your package name, you are on - # your own. - epoch1, upstream1, debian1 = Dpkg.split_full_version(str(ver1)) - epoch2, upstream2, debian2 = Dpkg.split_full_version(str(ver2)) - - # if epochs differ, immediately return the newer one - if epoch1 < epoch2: - return -1 - if epoch1 > epoch2: - return 1 - - # then, compare the upstream versions - upstr_res = Dpkg.compare_revision_strings(upstream1, upstream2) - if upstr_res != 0: - return upstr_res - - debian_res = Dpkg.compare_revision_strings(debian1, debian2) - if debian_res != 0: - return debian_res - - # at this point, the versions are equal, but due to an interpolated - # zero in either the epoch or the debian version - return 0 - diff --git a/packaging/ng/util/vercomp.py b/packaging/ng/util/vercomp.py @@ -0,0 +1,179 @@ +import re + +def order(char: str) -> int: + """ + Determines the sort weight of a character in the non-digit part of a version. + + According to deb-version(5): + 1. Tilde (~) sorts before everything. + 2. The end of a part sorts before anything else (except tilde). + 3. Letters sort earlier than non-letters (symbols). + """ + if char == '~': + return -1 + if char == '' or char is None: + return 0 + if char.isalpha(): + return ord(char) + # Map non-alphanumeric symbols (other than ~) higher than letters + # Standard ASCII letters are < 128, so adding 256 pushes symbols above them. + return ord(char) + 256 + +def compare_string_portions(val_a: str, val_b: str) -> int: + """ + Compares two string portions (upstream or debian revision) using the + algorithm described in deb-version(5). + """ + # Use lists as mutable cursors + list_a = list(val_a) + list_b = list(val_b) + + while list_a or list_b: + # --- 1. Compare non-digit initial parts --- + + # Extract non-digit prefixes + non_digit_a = [] + while list_a and not list_a[0].isdigit(): + non_digit_a.append(list_a.pop(0)) + + non_digit_b = [] + while list_b and not list_b[0].isdigit(): + non_digit_b.append(list_b.pop(0)) + + # Compare them character by character + # We loop until we exhaust the longest of the two non-digit strings + # (effectively padding the shorter one with empty strings/None) + len_cmp = max(len(non_digit_a), len(non_digit_b)) + + for i in range(len_cmp): + # Get chars or None if exhausted + ca = non_digit_a[i] if i < len(non_digit_a) else None + cb = non_digit_b[i] if i < len(non_digit_b) else None + + wa = order(ca) + wb = order(cb) + + if wa < wb: return -1 + if wa > wb: return 1 + + # --- 2. Compare numerical parts --- + + # Extract digit prefixes + digit_a_str = [] + while list_a and list_a[0].isdigit(): + digit_a_str.append(list_a.pop(0)) + + digit_b_str = [] + while list_b and list_b[0].isdigit(): + digit_b_str.append(list_b.pop(0)) + + # Convert to int (empty string defaults to 0) + num_a = int("".join(digit_a_str)) if digit_a_str else 0 + num_b = int("".join(digit_b_str)) if digit_b_str else 0 + + if num_a < num_b: return -1 + if num_a > num_b: return 1 + + return 0 + +def parse_version(version: str): + """ + Parses a version string into (epoch, upstream, debian). + Defaults: epoch=0, debian='0' if missing. + """ + epoch = 0 + upstream = version + debian = "0" + + # 1. Parse Epoch (last colon wins? No, first colon) + # deb-version: "The epoch is a single (generally small) unsigned integer + # and may be omitted. If it is omitted then the upstream_version may + # not contain any colons." + if ':' in version: + epoch_str, remainder = version.split(':', 1) + if epoch_str.isdigit(): + epoch = int(epoch_str) + upstream = remainder + + # 2. Parse Debian Revision (last hyphen splits upstream and debian) + # deb-version: "If there is no debian_revision then hyphens are not + # allowed [in upstream]; if there is no debian_revision then the + # upstream_version is the whole version string." + r_hyphen_index = upstream.rfind('-') + if r_hyphen_index != -1: + # Found a hyphen, split + debian = upstream[r_hyphen_index + 1:] + upstream = upstream[:r_hyphen_index] + + return epoch, upstream, debian + +def compare_versions(ver_a: str, ver_b: str) -> int: + """ + Compare two Debian version strings. + + Returns: + -1 if ver_a < ver_b + 0 if ver_a == ver_b + 1 if ver_a > ver_b + """ + # 1. Parse + epoch_a, up_a, deb_a = parse_version(ver_a) + epoch_b, up_b, deb_b = parse_version(ver_b) + + # 2. Compare Epochs (Integers) + if epoch_a < epoch_b: return -1 + if epoch_a > epoch_b: return 1 + + # 3. Compare Upstream Versions (String Logic) + res = compare_string_portions(up_a, up_b) + if res != 0: return res + + # 4. Compare Debian Revisions (String Logic) + return compare_string_portions(deb_a, deb_b) + +# --- Tests and Examples --- +if __name__ == "__main__": + test_cases = [ + # (Version A, Version B, Operator Expected) + # 1.0 < 1.1 + ("1.0", "1.1", "<"), + # 1.0-1 < 1.0-2 + ("1.0-1", "1.0-2", "<"), + # Tilde logic: 1.0~rc1 < 1.0 + ("1.0~rc1", "1.0", "<"), + # Tilde sorts before empty string (end of chunk) + # "1.0" parses as 1, ., 0, (end) + # "1.0~" parses as 1, ., 0, ~ + # ~ < (end), so 1.0~ < 1.0 + ("1.0~", "1.0", "<"), + # Letter vs Symbol logic: 1.0a < 1.0+ + # 'a' (letter) sorts earlier than '+' (symbol) + ("1.0a", "1.0+", "<"), + # Epoch logic + ("1:1.0", "1.0", ">"), + # Digit comparison: 1.0 == 1.00 + ("1.0", "1.00", "="), + # Parsing split logic + ("1.2.3-1", "1.2.3-1", "="), + ("1.2.3-alpha", "1.2.3-beta", "<"), + # Tilde + ("1.1.2~trixie", "1.1.2", "<"), + ] + + print(f"{'Version A':<15} {'Op':<5} {'Version B':<15} {'Result':<10}") + print("-" * 50) + + for va, vb, expected_op in test_cases: + result = compare_versions(va, vb) + + op_map = { -1: "<", 0: "=", 1: ">" } + actual_op = op_map[result] + + # Check correctness + passed = False + if expected_op == "<" and result == -1: passed = True + elif expected_op == ">" and result == 1: passed = True + elif expected_op == "=" and result == 0: passed = True + + status = "PASS" if passed else "FAIL" + print(f"{va:<15} {actual_op:<5} {vb:<15} {status}")