From b09e52b2d6bdb22fabaaf83c86545d00a1342446 Mon Sep 17 00:00:00 2001 From: Ludovic Henry Date: Tue, 13 Oct 2026 06:38:05 +0000 Subject: [PATCH 1/3] Add riscv64 wheel build for python-active-directory 2.0.1 An MIT-licensed LDAP/AD client with one small C extension (activedirectory.protocol.krb5) that links directly against libkrb5 - no GSSAPI, no SIMD, no vendored library tree. krb5-devel builds it; openldap- devel and cyrus-sasl-devel are for python-ldap, an install_requires with no riscv64 wheel anywhere (it has never published one on any platform) that pip builds from its sdist. Default tests/test.conf.example disables the tests that need a real Active Directory server, matching upstream's own tox.ini. keyutils-libs is the one copyleft library expected off the same krb5 dependency chain ssh-python already vendors on this image. --- .../build-python-active-directory.yml | 218 ++++++++++++++++++ docs/packages/python-active-directory.yaml | 5 + 2 files changed, 223 insertions(+) create mode 100644 .github/workflows/build-python-active-directory.yml create mode 100644 docs/packages/python-active-directory.yaml diff --git a/.github/workflows/build-python-active-directory.yml b/.github/workflows/build-python-active-directory.yml new file mode 100644 index 000000000000..9a7d1415a3b8 --- /dev/null +++ b/.github/workflows/build-python-active-directory.yml @@ -0,0 +1,218 @@ +# SPDX-FileCopyrightText: 2026 The RISE Project +# SPDX-License-Identifier: MIT +--- +name: Build python-active-directory wheels (riscv64) + +on: + workflow_dispatch: + inputs: + version: + description: 'Version glob to (re)build; empty builds every version of docs/packages/python-active-directory.yaml not released yet' + required: false + default: '' + pull_request: + branches: [main] + paths: + - '.github/workflows/build-python-active-directory.yml' + - 'docs/packages/python-active-directory.yaml' + push: + branches: [main] + paths: + - '.github/workflows/build-python-active-directory.yml' + - 'docs/packages/python-active-directory.yaml' + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read # to fetch code (actions/checkout) + +env: + MANYLINUX_RISCV64_IMAGE: quay.io/pypa/manylinux_2_39_riscv64 + +jobs: + setup: + uses: $/.github/workflows/_setup.yml + with: + package: python-active-directory + version: ${{ inputs.version }} + + build_wheels: + needs: [setup] + if: needs.setup.outputs.versions != '[]' + name: Build python-active-directory ${{ matrix.version }} ${{ matrix.python }}-manylinux_riscv64 + runs-on: ubuntu-24.04-riscv + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + version: ${{ fromJSON(needs.setup.outputs.versions) }} + python: + - "cp312" + - "cp313" + - "cp314" + - "cp314t" + + env: + PYTHON_ACTIVE_DIRECTORY_VERSION: ${{ matrix.version }} + + steps: + - name: Checkout python-active-directory v${{ env.PYTHON_ACTIVE_DIRECTORY_VERSION }} + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: theatlantic/python-active-directory + ref: v${{ env.PYTHON_ACTIVE_DIRECTORY_VERSION }} + persist-credentials: false + + - name: Stage the licence-collection script + run: | + cat > collect-licenses.sh <<'COLLECT_EOF' + #!/bin/bash + # SPDX-FileCopyrightText: 2026 The RISE Project + # SPDX-License-Identifier: MIT + # + # Stage, at the project root, the licence of every shared library + # auditwheel vendors out of the build image alongside the krb5.c + # extension (the MIT Kerberos stack). setuptools' default + # LICEN[CS]E* glob copies them into the wheel. + set -euo pipefail + + project="${1:?usage: collect-licenses.sh }" + + mapfile -t libs < <( + ldd /usr/lib64/libkrb5.so | tr ' ' '\n' | grep '^/' + readlink -f /usr/lib64/libkrb5.so + ) + mapfile -t libs < <(printf '%s\n' "${libs[@]}" | sort -u) + + mapfile -t pkgs < <( + rpm -qf --qf '%{NAME}\n' "${libs[@]}" 2>/dev/null | + grep -E '^[A-Za-z0-9._+-]+$' | sort -u | + grep -vE '^(glibc|libgcc|libstdc\+\+|gcc|zlib-ng-compat)$' + ) + + for pkg in "${pkgs[@]}"; do + rpm -q --qf '%{NAME}: %{LICENSE}\n' "$pkg" + mapfile -t files < <(rpm -q --licensefiles "$pkg" 2>/dev/null || true) + + if [ -z "${files[0]:-}" ]; then + srpm=$(rpm -q --qf '%{SOURCERPM}\n' "$pkg") + mapfile -t files < <( + rpm -qa --qf '%{SOURCERPM} %{NAME}\n' | + awk -v s="$srpm" '$1 == s { print $2 }' | + xargs -r rpm -q --licensefiles 2>/dev/null | sort -u + ) + fi + + if [ -z "${files[0]:-}" ]; then + dnf -y --disablerepo=extras reinstall --setopt=tsflags= "$pkg" >/dev/null + mapfile -t files < <(rpm -qd "$pkg" | grep -iE '/(LICEN[CS]E|COPYING|NOTICE)') + fi + + for f in "${files[@]}"; do + [ -f "$f" ] || continue + cp "$f" "$project/LICENSE.${pkg}.$(basename "$f")" + done + compgen -G "$project/LICENSE.$pkg.*" >/dev/null || + { echo "no licence file found for $pkg" >&2; exit 1; } + done + + ls -1 "$project"/LICENSE.* | sed "s|$project/||" + COLLECT_EOF + + - name: Build wheels + uses: pypa/cibuildwheel@1828c10ab37f080699c7b81cea34097c684a7074 # v4.2.0 + with: + output-dir: wheelhouse/ + only: ${{ matrix.python }}-manylinux_riscv64 + env: + CIBW_MANYLINUX_RISCV64_IMAGE: ${{ env.MANYLINUX_RISCV64_IMAGE }} + # krb5-devel builds our own extension; openldap-devel/cyrus-sasl-devel + # are for python-ldap (install_requires, no riscv64 wheel anywhere - + # it builds from its sdist whenever pip resolves it, here and for + # every consumer on this registry). + CIBW_BEFORE_ALL_LINUX: >- + dnf -y install krb5-devel openldap-devel cyrus-sasl-devel && + bash {project}/collect-licenses.sh {project} + CIBW_ENVIRONMENT: PIP_EXTRA_INDEX_URL=https://pypi.riseproject.dev/simple/ + CIBW_TEST_REQUIRES: pytest pexpect + # tests/ is a package whose sibling lib/ source dir would shadow the + # installed wheel (gotcha 25). + CIBW_TEST_SOURCES: tests + # Default tests/test.conf.example disables readonly/intrusive AD + # tests (conf.require() skips them), so the suite needs no real + # Active Directory server - matches upstream's own tox.ini. + CIBW_TEST_COMMAND: python -m pytest -q -ra tests + + - name: Check the wheel ships the extension and the licences + run: | + python3 - wheelhouse/*.whl <<'EOF' + import sys, zipfile + for whl in sys.argv[1:]: + names = zipfile.ZipFile(whl).namelist() + assert any(n.startswith("activedirectory/protocol/krb5") and n.endswith(".so") for n in names), (whl, names) + lic = {n.rsplit("/", 1)[1] for n in names if ".dist-info/licenses/" in n} - {""} + print(whl, sorted(lic)) + assert "LICENSE" in lic, lic + pkgs = {f.split(".", 2)[1] for f in lic if f.startswith("LICENSE.") and f.count(".") >= 2} + assert "krb5-libs" in pkgs, pkgs + EOF + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: python-active-directory-${{ env.PYTHON_ACTIVE_DIRECTORY_VERSION }}-${{ matrix.python }}-manylinux_riscv64 + path: wheelhouse/*.whl + if-no-files-found: error + + gpl_sources: + needs: [setup] + if: needs.setup.outputs.versions != '[]' + strategy: + fail-fast: false + matrix: + version: ${{ fromJSON(needs.setup.outputs.versions) }} + name: Collect GPL sources for python-active-directory ${{ matrix.version }} + runs-on: ubuntu-24.04-riscv + + env: + PYTHON_ACTIVE_DIRECTORY_VERSION: ${{ matrix.version }} + + steps: + - name: Checkout python-wheels + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # keyutils-libs is the one copyleft library auditwheel vendors out of + # the build image alongside libkrb5 (same krb5 stack as ssh-python). + - uses: ./actions/collect-gpl-sources + with: + image: ${{ env.MANYLINUX_RISCV64_IMAGE }} + packages: gcc keyutils-libs + output: gpl-sources.tar + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: python-active-directory-${{ env.PYTHON_ACTIVE_DIRECTORY_VERSION }}-gpl-sources + path: gpl-sources.tar + if-no-files-found: error + + publish: + name: Publish python-active-directory ${{ matrix.version }} + needs: [setup, build_wheels, gpl_sources] + if: needs.setup.outputs.versions != '[]' + strategy: + fail-fast: false + matrix: + version: ${{ fromJSON(needs.setup.outputs.versions) }} + permissions: + contents: write + pull-requests: write + uses: $/.github/workflows/_publish-wheel.yml + secrets: + app-private-key: ${{ secrets.RISEPROJECT_APP_PRIVATE_KEY }} + with: + artifact-pattern: python-active-directory-${{ matrix.version }}-*-manylinux_riscv64 + gpl-sources-artifact: python-active-directory-${{ matrix.version }}-gpl-sources + gpl-sources-description: gcc diff --git a/docs/packages/python-active-directory.yaml b/docs/packages/python-active-directory.yaml new file mode 100644 index 000000000000..b4c106d31fc3 --- /dev/null +++ b/docs/packages/python-active-directory.yaml @@ -0,0 +1,5 @@ +package-name: python-active-directory +source-code: https://github.com/theatlantic/python-active-directory +license: MIT +versions: +- version: 2.0.1 From 3e60bdcb1a16384676a83c22442a21f4d7add9c2 Mon Sep 17 00:00:00 2001 From: Ludovic Henry Date: Thu, 22 Oct 2026 11:09:18 +0000 Subject: [PATCH 2/3] python-active-directory: patch out distutils for Python 3.12+ Every riscv64 job built the wheel, then failed in the test step: activedirectory.util.compat does `from distutils import version` at import time, and distutils was removed from the standard library in Python 3.12 (PEP 632), so the conftest import dies with ModuleNotFoundError on cp312, cp313, cp314 and cp314t alike. It only used StrictVersion to compare python-ldap's version against 2.4.0. Carry a patch that replaces those checks with a (major, minor) tuple comparison instead of adding setuptools to the test requirements, which would have hidden that the shipped wheel cannot be imported on 3.12+ without setuptools installed. --- .../build-python-active-directory.yml | 11 +++ ...ompat-drop-distutils-for-Python-3.12.patch | 74 +++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 patches/python-active-directory/2.0.1/0001-compat-drop-distutils-for-Python-3.12.patch diff --git a/.github/workflows/build-python-active-directory.yml b/.github/workflows/build-python-active-directory.yml index 9a7d1415a3b8..aa5918982910 100644 --- a/.github/workflows/build-python-active-directory.yml +++ b/.github/workflows/build-python-active-directory.yml @@ -15,11 +15,13 @@ on: paths: - '.github/workflows/build-python-active-directory.yml' - 'docs/packages/python-active-directory.yaml' + - 'patches/python-active-directory/**' push: branches: [main] paths: - '.github/workflows/build-python-active-directory.yml' - 'docs/packages/python-active-directory.yaml' + - 'patches/python-active-directory/**' concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} @@ -65,6 +67,15 @@ jobs: ref: v${{ env.PYTHON_ACTIVE_DIRECTORY_VERSION }} persist-credentials: false + - name: Checkout python-wheels + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: python-wheels + persist-credentials: false + + - name: Apply patches + run: git apply python-wheels/patches/python-active-directory/${{ env.PYTHON_ACTIVE_DIRECTORY_VERSION }}/*.patch + - name: Stage the licence-collection script run: | cat > collect-licenses.sh <<'COLLECT_EOF' diff --git a/patches/python-active-directory/2.0.1/0001-compat-drop-distutils-for-Python-3.12.patch b/patches/python-active-directory/2.0.1/0001-compat-drop-distutils-for-Python-3.12.patch new file mode 100644 index 000000000000..cc0f9930de79 --- /dev/null +++ b/patches/python-active-directory/2.0.1/0001-compat-drop-distutils-for-Python-3.12.patch @@ -0,0 +1,74 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Ludovic Henry +Date: Thu, 22 Oct 2026 00:00:00 +0000 +Subject: [PATCH] compat: drop distutils for Python 3.12+ + +activedirectory.util.compat imports distutils.version at module load +time, only to compare python-ldap's version against 2.4.0 with +StrictVersion. distutils was removed from the standard library in +Python 3.12 (PEP 632), so `import activedirectory` fails with +"ModuleNotFoundError: No module named 'distutils'" on every interpreter +this registry builds for (cp312, cp313, cp314, cp314t), unless +setuptools happens to be installed and injects its vendored copy. + +Replace the four StrictVersion comparisons with one module-level +(major, minor) tuple comparison computed from ldap.__version__. The +behaviour is unchanged: install_requires already pins python-ldap>=3.0, +so the check is always true for a supported install. + +Upstream-Status: To upstream [not yet submitted; upstream's tox.ini only covers py37/py38] + +Signed-off-by: Ludovic Henry +--- + lib/activedirectory/util/compat.py | 12 +++++++----- + 1 file changed, 7 insertions(+), 5 deletions(-) + +diff --git a/lib/activedirectory/util/compat.py b/lib/activedirectory/util/compat.py +index eb6a68a..f4d3462 100644 +--- a/lib/activedirectory/util/compat.py ++++ b/lib/activedirectory/util/compat.py +@@ -9,7 +9,9 @@ + import ldap + import ldap.dn + +-from distutils import version ++# distutils was removed from the standard library in Python 3.12 (PEP 632); ++# all it was used for here is comparing python-ldap's version against 2.4. ++_LDAP_2_4 = tuple(int(x) for x in ldap.__version__.split('.')[:2]) >= (2, 4) + + # ldap.str2dn has been removed in python-ldap >= 2.3.6. We now need to use + # the version in ldap.dn. +@@ -22,7 +24,7 @@ def disable_reverse_dns(): + # Possibly add in a Kerberos minimum version check as well... + return hasattr(ldap, 'OPT_X_SASL_NOCANON') + +-if version.StrictVersion('2.4.0') <= version.StrictVersion(ldap.__version__): ++if _LDAP_2_4: + LDAP_CONTROL_PAGED_RESULTS = ldap.CONTROL_PAGEDRESULTS + else: + LDAP_CONTROL_PAGED_RESULTS = ldap.LDAP_CONTROL_PAGE_OID +@@ -36,7 +38,7 @@ class SimplePagedResultsControl(ldap.controls.SimplePagedResultsControl): + """ + + def __init__(self, page_size=0, cookie=''): +- if version.StrictVersion('2.4.0') <= version.StrictVersion(ldap.__version__): ++ if _LDAP_2_4: + ldap.controls.SimplePagedResultsControl.__init__( + self, + size=page_size, +@@ -51,13 +53,13 @@ class SimplePagedResultsControl(ldap.controls.SimplePagedResultsControl): + ) + + def cookie(self): +- if version.StrictVersion('2.4.0') <= version.StrictVersion(ldap.__version__): ++ if _LDAP_2_4: + return self.cookie + else: + return self.controlValue[1] + + def size(self): +- if version.StrictVersion('2.4.0') <= version.StrictVersion(ldap.__version__): ++ if _LDAP_2_4: + return self.size + else: + return self.controlValue[0] From f91869042af592ab50f006a6b09076df90fbe4b3 Mon Sep 17 00:00:00 2001 From: Ludovic Henry Date: Fri, 23 Oct 2026 15:35:31 +0000 Subject: [PATCH 3/3] python-active-directory: fix swapped site names in netlogon test With the distutils patch in place every riscv64 job gets through the test suite, but tests/protocol/test_netlogon.py::TestDecoder:: test_real_packet fails on cp312, cp313, cp314 and cp314t alike: "assert b'Test-Site' == b'Default-First-Site'". The failure is not riscv64-specific. netlogon.Decoder.parse() decodes DcSiteName before ClientSiteName, the NETLOGON_SAM_LOGON_RESPONSE_EX order from MS-ADTS; releases up to 1.0.5 decoded them the other way round, and the test still asserts that old assignment. The fixture packet carries "Default-First-Site" as the DC site and "Test-Site" as the client site. Reproduced on x86_64 with Python 3.12 and 3.13 against the v2.0.1 tree. Carry a patch that swaps the two expected values instead of deselecting the test, so the decoder keeps its only real-packet coverage. --- ...apped-site-names-in-test_real_packet.patch | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 patches/python-active-directory/2.0.1/0002-tests-netlogon-fix-swapped-site-names-in-test_real_packet.patch diff --git a/patches/python-active-directory/2.0.1/0002-tests-netlogon-fix-swapped-site-names-in-test_real_packet.patch b/patches/python-active-directory/2.0.1/0002-tests-netlogon-fix-swapped-site-names-in-test_real_packet.patch new file mode 100644 index 000000000000..7d39a29ceaef --- /dev/null +++ b/patches/python-active-directory/2.0.1/0002-tests-netlogon-fix-swapped-site-names-in-test_real_packet.patch @@ -0,0 +1,36 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Ludovic Henry +Date: Fri, 23 Oct 2026 00:00:00 +0000 +Subject: [PATCH] tests: netlogon: fix swapped site names in test_real_packet + +netlogon.Decoder.parse() decodes DcSiteName before ClientSiteName, the +order of NETLOGON_SAM_LOGON_RESPONSE_EX in MS-ADTS 2.2.1.9; releases up +to 1.0.5 decoded them the other way round. The test still asserts the +old assignment, so it fails on every platform: tests/protocol/netlogon.bin +carries "Default-First-Site" as the DC site and "Test-Site" as the client +site. + +Swap the two expected values to match the decoder and the packet. + +Upstream-Status: To upstream [not yet submitted; upstream has no CI running the test suite] + +Signed-off-by: Ludovic Henry +--- + tests/protocol/test_netlogon.py | 4 ++-- + 1 file changed, 2 insertions(+), 2 deletions(-) + +diff --git a/tests/protocol/test_netlogon.py b/tests/protocol/test_netlogon.py +index 711b5f6..519524b 100644 +--- a/tests/protocol/test_netlogon.py ++++ b/tests/protocol/test_netlogon.py +@@ -191,8 +191,8 @@ class TestDecoder(object): + res = dec.parse() + assert res.forest == b'freeadi.org' + assert res.domain == b'freeadi.org' +- assert res.client_site == b'Default-First-Site' +- assert res.server_site == b'Test-Site' ++ assert res.server_site == b'Default-First-Site' ++ assert res.client_site == b'Test-Site' + + def test_error_short_input(self): + buf = b'x' * 24