diff --git a/.github/workflows/build-portforward.yml b/.github/workflows/build-portforward.yml new file mode 100644 index 00000000000..9e90f83c99b --- /dev/null +++ b/.github/workflows/build-portforward.yml @@ -0,0 +1,297 @@ +# SPDX-FileCopyrightText: 2026 The RISE Project +# SPDX-License-Identifier: MIT +--- +# This workflow is based on the `linux` job of +# https://github.com/pytogo/portforward/blob/v0.7.6/.github/workflows/release.yml +name: Build portforward wheels (riscv64) + +on: + workflow_dispatch: + inputs: + version: + description: 'Version glob to (re)build; empty builds every version of docs/packages/portforward.yaml not released yet' + required: false + default: '' + pull_request: + branches: [main] + paths: + - '.github/workflows/build-portforward.yml' + - 'docs/packages/portforward.yaml' + push: + branches: [main] + paths: + - '.github/workflows/build-portforward.yml' + - 'docs/packages/portforward.yaml' + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read # to fetch code (actions/checkout) + +jobs: + setup: + uses: $/.github/workflows/_setup.yml + with: + package: portforward + version: ${{ inputs.version }} + + build_wheels: + needs: [setup] + if: needs.setup.outputs.versions != '[]' + name: Build portforward ${{ matrix.version }} ${{ matrix.python }}-manylinux_riscv64 + runs-on: ubuntu-24.04-riscv + timeout-minutes: 180 + strategy: + fail-fast: false + matrix: + version: ${{ fromJSON(needs.setup.outputs.versions) }} + python: ['cp312', 'cp313', 'cp314', 'cp314t'] + + env: + PORTFORWARD_VERSION: ${{ matrix.version }} + + steps: + - name: Checkout portforward v${{ env.PORTFORWARD_VERSION }} + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: pytogo/portforward + ref: v${{ env.PORTFORWARD_VERSION }} + persist-credentials: false + + - name: Determine target interpreter + id: interp + run: | + v="${{ matrix.python }}" + v="${v#cp}" + echo "value=python${v:0:1}.${v:1}" >> "$GITHUB_OUTPUT" + + - name: Build wheel + uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 + with: + target: riscv64gc-unknown-linux-gnu + args: --release --out dist -i ${{ steps.interp.outputs.value }} + manylinux: '2_39' + before-script-linux: yum install -y perl-core openssl openssl-devel pkgconfig libatomic + + - name: Verify the wheel ships the compiled extension + run: | + set -euo pipefail + python3 - dist/*.whl <<'EOF' + import sys, zipfile + names = zipfile.ZipFile(sys.argv[1]).namelist() + sos = [n for n in names if n.endswith(".so")] + assert any(n.startswith("portforward/_portforward.") for n in sos), sos + assert any(n.endswith(".dist-info/licenses/LICENSE") for n in names), names + print(sys.argv[1], "->", sos) + EOF + + - name: Install Python + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + python-version: ${{ steps.interp.outputs.value }} + activate-environment: true + enable-cache: false + + # Upstream's tests/test_portforward.py needs a pytest-kind cluster; this + # drives the extension against a local TLS fake of the Kubernetes API instead. + - name: Stage the riscv64 tests + run: | + mkdir -p riscv64-tests + cat > riscv64-tests/test_riscv64_smoke.py <<'EOF' + import json + import socket + import ssl + import subprocess + import threading + import time + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + from urllib.parse import parse_qs, urlsplit + + import pytest + + import portforward + + NAMESPACE = "pftest" + POD = "test-pod" + SERVICE = "web-svc" + POD_OBJ = { + "apiVersion": "v1", + "kind": "Pod", + "metadata": {"name": POD, "namespace": NAMESPACE, "resourceVersion": "1", "labels": {"app": "web"}}, + "spec": {"containers": [{"name": "web", "image": "web"}]}, + "status": {"phase": "Running"}, + } + SERVICE_OBJ = { + "apiVersion": "v1", + "kind": "Service", + "metadata": {"name": SERVICE, "namespace": NAMESPACE, "resourceVersion": "1"}, + "spec": {"selector": {"app": "web"}, "ports": [{"port": 80}]}, + } + NOT_FOUND = {"apiVersion": "v1", "kind": "Status", "metadata": {}, "status": "Failure", + "message": "not found", "reason": "NotFound", "code": 404} + + + class FakeApiServer(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, *args): + pass + + def _send(self, code, body): + data = json.dumps(body).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def do_GET(self): + url = urlsplit(self.path) + query = parse_qs(url.query) + prefix = f"/api/v1/namespaces/{NAMESPACE}" + if url.path == f"{prefix}/pods/{POD}": + return self._send(200, POD_OBJ) + if url.path == f"{prefix}/services/{SERVICE}": + return self._send(200, SERVICE_OBJ) + if url.path == f"{prefix}/pods" and "watch" not in query: + selector = query.get("labelSelector", []) + query.get("fieldSelector", []) + if selector in (["app=web"], [f"metadata.name={POD}"]): + return self._send(200, {"apiVersion": "v1", "kind": "PodList", + "metadata": {"resourceVersion": "1"}, "items": [POD_OBJ]}) + return self._send(404, NOT_FOUND) + + + def _openssl(*args, cwd): + subprocess.run(["openssl", *args], cwd=cwd, check=True, capture_output=True) + + + def _make_ca(tmp, name): + _openssl("req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1", "-subj", f"/CN={name}", + "-keyout", f"{name}.key", "-out", f"{name}.crt", cwd=tmp) + return tmp / f"{name}.crt" + + + @pytest.fixture(autouse=True) + def no_proxy(monkeypatch): + for var in ("HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"): + monkeypatch.delenv(var, raising=False) + + + @pytest.fixture(scope="module") + def pki(tmp_path_factory): + tmp = tmp_path_factory.mktemp("pki") + ca = _make_ca(tmp, "ca") + other_ca = _make_ca(tmp, "other-ca") + _openssl("req", "-newkey", "rsa:2048", "-nodes", "-subj", "/CN=127.0.0.1", + "-keyout", "server.key", "-out", "server.csr", cwd=tmp) + (tmp / "ext.cnf").write_text( + "basicConstraints=CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\n" + "extendedKeyUsage=serverAuth\nsubjectAltName=IP:127.0.0.1\n" + ) + _openssl("x509", "-req", "-in", "server.csr", "-CA", "ca.crt", "-CAkey", "ca.key", + "-CAcreateserial", "-days", "1", "-extfile", "ext.cnf", "-out", "server.crt", cwd=tmp) + return {"dir": tmp, "ca": ca, "other_ca": other_ca} + + + @pytest.fixture(scope="module") + def api_server(pki): + httpd = ThreadingHTTPServer(("127.0.0.1", 0), FakeApiServer) + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ctx.load_cert_chain(pki["dir"] / "server.crt", pki["dir"] / "server.key") + httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True) + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + yield f"https://127.0.0.1:{httpd.server_address[1]}" + httpd.shutdown() + + + def _kubeconfig(tmp_path, server, ca): + config = { + "apiVersion": "v1", + "kind": "Config", + "clusters": [{"name": "fake", "cluster": {"server": server, "certificate-authority": str(ca)}}], + "users": [{"name": "fake", "user": {"token": "fake-token"}}], + "contexts": [{"name": "fake", "context": {"cluster": "fake", "user": "fake", "namespace": NAMESPACE}}], + "current-context": "fake", + } + path = tmp_path / "kubeconfig" + path.write_text(json.dumps(config)) + return str(path) + + + def _listening(port): + try: + socket.create_connection(("127.0.0.1", port), timeout=1).close() + return True + except OSError: + return False + + + def test_extension_is_compiled(): + from portforward import _portforward + + assert _portforward.__file__.endswith(".so"), _portforward.__file__ + + + @pytest.mark.parametrize("target", [POD, SERVICE]) + def test_forward_binds_and_stops(tmp_path, pki, api_server, target): + config = _kubeconfig(tmp_path, api_server, pki["ca"]) + with portforward.forward(NAMESPACE, target, 0, 3000, config_path=config, + log_level=portforward.LogLevel.OFF) as forwarder: + assert forwarder._async_forwarder.actual_pod_name == POD + port = forwarder.from_port + assert port > 0 + assert _listening(port) + for _ in range(50): + if not _listening(port): + break + time.sleep(0.1) + assert not _listening(port) + + + def test_unknown_target_raises(tmp_path, pki, api_server): + config = _kubeconfig(tmp_path, api_server, pki["ca"]) + with pytest.raises(portforward.PortforwardError, match="NotFound"): + with portforward.forward(NAMESPACE, "missing", 0, 3000, config_path=config, + log_level=portforward.LogLevel.OFF): + pass + + + def test_untrusted_server_certificate_is_rejected(tmp_path, pki, api_server): + config = _kubeconfig(tmp_path, api_server, pki["other_ca"]) + with pytest.raises(portforward.PortforwardError, match="(?i)certificate|UnknownIssuer"): + with portforward.forward(NAMESPACE, POD, 0, 3000, config_path=config, + log_level=portforward.LogLevel.OFF): + pass + EOF + + - name: Test wheel + run: | + uv pip install --reinstall dist/*.whl + uv pip install pytest + python -m pytest -v riscv64-tests + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: portforward-${{ env.PORTFORWARD_VERSION }}-${{ matrix.python }}-manylinux_riscv64 + path: dist/*.whl + if-no-files-found: error + + publish: + name: Publish portforward ${{ matrix.version }} + needs: [setup, build_wheels] + 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: portforward-${{ matrix.version }}-*-manylinux_riscv64 diff --git a/docs/packages/portforward.yaml b/docs/packages/portforward.yaml new file mode 100644 index 00000000000..9b89cbb43f2 --- /dev/null +++ b/docs/packages/portforward.yaml @@ -0,0 +1,5 @@ +package-name: portforward +source-code: https://github.com/pytogo/portforward +license: MIT +versions: +- version: 0.7.6