diff --git a/.github/workflows/build-python-active-directory.yml b/.github/workflows/build-python-active-directory.yml new file mode 100644 index 000000000000..aa5918982910 --- /dev/null +++ b/.github/workflows/build-python-active-directory.yml @@ -0,0 +1,229 @@ +# 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' + - '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 }} + 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: 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' + #!/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 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] 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