docs: Update projects #4
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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 |