[RFC PATCH 12/12] drm/fabric: add mutation netlink selftests
Konstantin Sinyuk <[email protected]>
| Newsgroups | org.kernel.vger.linux-doc,org.freedesktop.lists.dri-devel,org.kernel.vger.linux-kernel,org.kernel.vger.linux-kselftest,org.kernel.vger.netdev |
|---|---|
| Message-ID | <5e1a345a60d23b9d13211be009410f9a30ec3bfc.1787552412.git.ksinyuk@kernel.org> |
Add three programs covering CAP_NET_ADMIN enforcement, non-init_net rejection and end-to-end provisioning: orphan attach, administrative-state changes, peer installation, link failure and recovery. Extend the query, hotplug, fault and policy tests for the mutation operations. The policy probe now builds nested requests with NLA_F_NESTED, so an out-of-range nested member reaches the range check and is refused with -ERANGE instead of as a malformed nest. The run reports 172 results across fourteen programs, all passing in a booted virtme-ng guest. Signed-off-by: Konstantin Sinyuk <[email protected]> Assisted-by: GitHub-Copilot:claude-opus-4.8 --- Documentation/gpu/drm-fabric.rst | 3 +- .../selftests/drivers/gpu/drm_fabric/Makefile | 3 + .../drivers/gpu/drm_fabric/README.rst | 20 +- .../drivers/gpu/drm_fabric/cap_netadmin.py | 314 +++++++++++++++++ .../selftests/drivers/gpu/drm_fabric/config | 5 + .../drivers/gpu/drm_fabric/fabric_abi.py | 156 ++++++++- .../drivers/gpu/drm_fabric/fault_abi.py | 211 +++++++++++- .../drivers/gpu/drm_fabric/hotplug_abi.py | 145 +++++++- .../drivers/gpu/drm_fabric/lib_drm_fabric.py | 17 + .../drivers/gpu/drm_fabric/netns_abi.py | 294 ++++++++++++++++ .../drivers/gpu/drm_fabric/nl_policy_probe.py | 280 ++++++++++++--- .../drm_fabric/provisioning_scenarios_abi.py | 324 ++++++++++++++++++ .../drivers/gpu/drm_fabric/switch_abi.py | 31 +- 13 files changed, 1721 insertions(+), 82 deletions(-) create mode 100755 tools/testing/selftests/drivers/gpu/drm_fabric/cap_netadmin.py create mode 100755 tools/testing/selftests/drivers/gpu/drm_fabric/netns_abi.py create mode 100755 tools/testing/selftests/drivers/gpu/drm_fabric/provisioning_scenarios_abi.py diff --git a/Documentation/gpu/drm-fabric.rst b/Documentation/gpu/drm-fabric.rst index 1fd48027eee3..e400c44d32b8 100644 --- a/Documentation/gpu/drm-fabric.rst +++ b/Documentation/gpu/drm-fabric.rst @@ -474,5 +474,6 @@ Generic Netlink ABI tests live under ``tools/testing/selftests/drivers/gpu/drm_fabric``. They cover the YNL query paths, malformed policy input, generated-header synchronization, dump-cursor correctness across endpoint removal, ``NLM_F_DUMP_INTR`` handling, the opaque -switch half-edge, and provider fault handling. See that directory's ``README.rst`` +switch half-edge, ``CAP_NET_ADMIN`` gating and provisioning rejects, and +provider fault-injection failure atomicity. See that directory's ``README.rst`` for build and execution commands. diff --git a/tools/testing/selftests/drivers/gpu/drm_fabric/Makefile b/tools/testing/selftests/drivers/gpu/drm_fabric/Makefile index 54d756979d97..6cdf14c44a5a 100644 --- a/tools/testing/selftests/drivers/gpu/drm_fabric/Makefile +++ b/tools/testing/selftests/drivers/gpu/drm_fabric/Makefile @@ -18,7 +18,10 @@ TEST_PROGS := \ hotplug_abi.py \ dump_scale_abi.py \ switch_abi.py \ + cap_netadmin.py \ + netns_abi.py \ fault_abi.py \ + provisioning_scenarios_abi.py \ harness_reset_abi.py TEST_FILES := lib_drm_fabric.py diff --git a/tools/testing/selftests/drivers/gpu/drm_fabric/README.rst b/tools/testing/selftests/drivers/gpu/drm_fabric/README.rst index 6c24581db1f2..33d9618b31d5 100644 --- a/tools/testing/selftests/drivers/gpu/drm_fabric/README.rst +++ b/tools/testing/selftests/drivers/gpu/drm_fabric/README.rst @@ -4,7 +4,7 @@ drm_fabric selftests ==================== -These selftests exercise the ``drm-fabric`` query uAPI against +These selftests exercise the ``drm-fabric`` query and mutation uAPI against ``drm_fabric_sim`` using the in-tree YNL library. KUnit covers the core object model. @@ -45,7 +45,7 @@ Suites ends the dump. ``hotplug_abi.py`` - Endpoint hotplug: CREATE/DELETE notifications. + Endpoint hotplug: CREATE/DELETE NTFs and mutation round-trips. ``dump_scale_abi.py`` Dump resume under many endpoints (``bulk_add``). @@ -54,10 +54,19 @@ Suites Opaque switch peers whose identifiers do not resolve to an endpoint (``topology=switch``). +``cap_netadmin.py`` + ``CAP_NET_ADMIN`` enforcement for mutation commands. + +``netns_abi.py`` + Rejects commands outside ``init_net``, including with ``CAP_NET_ADMIN``. + ``fault_abi.py`` - Provider fault injection: errno propagation and no leaked endpoint + Provider failures: errno propagation, rollback and no notification (``fail_*``). +``provisioning_scenarios_abi.py`` + Endpoint, port and peer provisioning scenarios. + ``harness_reset_abi.py`` Recovery after a SIGKILL-terminated predecessor. @@ -71,6 +80,7 @@ A SKIP means a required precondition was unavailable. Environment ``check-spec-regen.sh`` needs PyYAML and writable temporary storage. + ``netns_abi.py`` needs ``CONFIG_NET_NS``. Per case A case skips when a required control, parameter or family capability is @@ -81,7 +91,7 @@ Whole suite Timing The two ``dump_intr_abi.py`` boundary cases may skip if the concurrent - topology change misses the required dump boundary. + mutation misses the required dump boundary. KUnit ----- @@ -133,4 +143,4 @@ Build out-of-tree, boot with ``vng`` and run the same target in the guest: make -C tools/testing/selftests TARGETS=drivers/gpu/drm_fabric run_tests Dependencies (Debian/Ubuntu): ``python3``, ``python3-yaml``, -``qemu-system-x86``, ``virtme-ng`` (``pip install --user virtme-ng``). \ No newline at end of file +``qemu-system-x86``, ``virtme-ng`` (``pip install --user virtme-ng``). diff --git a/tools/testing/selftests/drivers/gpu/drm_fabric/cap_netadmin.py b/tools/testing/selftests/drivers/gpu/drm_fabric/cap_netadmin.py new file mode 100755 index 000000000000..f3a80c719b7b --- /dev/null +++ b/tools/testing/selftests/drivers/gpu/drm_fabric/cap_netadmin.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-2.0 +# Copyright (c) 2026 Intel Corporation +""" +CAP_NET_ADMIN enforcement on the mutation commands: an unprivileged child +(forked, uid dropped before the socket opens) is refused with -EPERM; also +covers the -EINVAL/-ENOENT/-EEXIST rejection paths. + +Requires drm_fabric + drm_fabric_sim loaded; run as root. +""" + +import errno +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import lib_drm_fabric as L + +UNPRIV_UID = int(os.environ.get("UNPRIV_UID", "65534")) + + +def run_unpriv(method, vals): + """Run a single `do` under an unprivileged uid in a child process. + Returns (ok, err): ok True on success, err the positive errno on + NlError. Result crosses via a JSON line over a pipe. + """ + r, w = os.pipe() + pid = os.fork() + if pid == 0: # child + os.close(r) + result = {"kind": "exc", "val": "setup"} + try: + try: + os.setgroups([]) + except OSError: + pass + os.setresgid(UNPRIV_UID, UNPRIV_UID, UNPRIV_UID) + os.setresuid(UNPRIV_UID, UNPRIV_UID, UNPRIV_UID) + _, NlError = L.import_ynl() + fam = L.DrmFabric() + try: + fam.do(method, vals) + result = {"kind": "ok", "val": None} + except NlError as exc: + result = {"kind": "err", "val": exc.error} + except Exception as exc: # noqa: BLE001 + result = {"kind": "exc", "val": str(exc)} + os.write(w, json.dumps(result).encode()) + os.close(w) + os._exit(0) + + os.close(w) + buf = b"" + while True: + chunk = os.read(r, 4096) + if not chunk: + break + buf += chunk + os.close(r) + os.waitpid(pid, 0) + result = json.loads(buf.decode()) + return (result["kind"] == "ok", + result["val"] if result["kind"] == "err" else None) + + +class Cfg: + def __init__(self, fab, nl_error): + self.fab = fab + self.NlError = nl_error + + +def test_cap_fabric_new_privileged(ksft, cfg): + fab, NlError = cfg.fab, cfg.NlError + new_fid = None + try: + rep = fab.do("fabric-new", + {"fabric-new-params": {"type": "synthetic", + "name": "captest", + "instance-id": 0xCA9}}) + new_fid = rep.get("fabric-id") + ksft.check(new_fid is not None, "cap-fabric-new-privileged", + "reply=%s" % rep) + except NlError as exc: + ksft.not_ok("cap-fabric-new-privileged", "errno=%d" % exc.error) + if new_fid is not None: + try: + fab.do("fabric-del", {"fabric-id": new_fid}) + except NlError: + pass + + +def test_cap_fabric_new_unprivileged(ksft, cfg): + ok, err = run_unpriv("fabric-new", + {"fabric-new-params": {"type": "synthetic", + "name": "nope", + "instance-id": 0x4E0}}) + ksft.check(not ok and err == errno.EPERM, "cap-fabric-new-unprivileged-eperm", + "ok=%s errno=%s" % (ok, err)) + + +def test_cap_port_set_unprivileged(ksft, cfg): + ok, err = run_unpriv("port-set", + {"endpoint-id": 0, "port-index": 0, "admin-state": "down"}) + ksft.check(not ok and err == errno.EPERM, "cap-port-set-unprivileged-eperm", + "ok=%s errno=%s" % (ok, err)) + + +def test_cap_port_set_privileged(ksft, cfg): + fab, NlError = cfg.fab, cfg.NlError + ok_priv = True + detail = "" + try: + fab.do("port-set", {"endpoint-id": 0, "port-index": 0, "admin-state": "down"}) + except NlError as exc: + ok_priv = False + detail = "errno=%d" % exc.error + try: + fab.do("port-set", {"endpoint-id": 0, "port-index": 0, "admin-state": "up"}) + except NlError: + pass + ksft.check(ok_priv, "cap-port-set-privileged-ok", detail) + + +def test_cap_fabric_get_unprivileged(ksft, cfg): + ok, err = run_unpriv("fabric-get", {"fabric-id": 1}) + ksft.check(ok, "cap-fabric-get-unprivileged-ok", "errno=%s" % err) + + +def test_reject_fabric_del_unknown(ksft, cfg): + fab, NlError = cfg.fab, cfg.NlError + try: + fab.do("fabric-del", {"fabric-id": 4294967295}) + ksft.not_ok("reject-fabric-del-unknown-enoent", "accepted") + except NlError as exc: + ksft.check(exc.error == errno.ENOENT, "reject-fabric-del-unknown-enoent", + "errno=%d" % exc.error) + + +def test_reject_fabric_new_no_type(ksft, cfg): + fab, NlError = cfg.fab, cfg.NlError + try: + fab.do("fabric-new", {"fabric-new-params": {"name": "no-type"}}) + ksft.not_ok("reject-fabric-new-no-type-einval", "accepted") + except NlError as exc: + ksft.check(exc.error == errno.EINVAL, "reject-fabric-new-no-type-einval", + "errno=%d" % exc.error) + + +# A zero fabric type has no ynl symbolic name; the raw probe is in +# nl_policy_probe.py. + +USER_PORT = 3 + + +def test_reject_port_peer_new_provider_managed(ksft, cfg): + """PORT_PEER_NEW on a provider-managed port is refused with -EOPNOTSUPP.""" + fab, NlError = cfg.fab, cfg.NlError + try: + fab.do("port-peer-new", + {"endpoint-id": 0, "port-index": 0, + "peer": {"peer-id": 258, "type": "accel", "port-index": 0}}) + ksft.not_ok("reject-port-peer-new-provider-managed-eopnotsupp", "accepted") + except NlError as exc: + ksft.check(exc.error == errno.EOPNOTSUPP, + "reject-port-peer-new-provider-managed-eopnotsupp", + "errno=%d" % exc.error) + + +def test_userspace_peer_roundtrip(ksft, cfg): + """A userspace-managed port takes PORT_PEER_NEW, rejects a duplicate with + -EEXIST, and clears with PORT_PEER_DEL.""" + fab, NlError = cfg.fab, cfg.NlError + peer = {"peer-id": 258, "type": "accel", "port-index": 0} + try: + fab.do("port-peer-new", + {"endpoint-id": 0, "port-index": USER_PORT, "peer": peer}) + except NlError as exc: + ksft.not_ok("userspace-peer-new-ok", "errno=%d" % exc.error) + return + ksft.ok("userspace-peer-new-ok") + try: + fab.do("port-peer-new", + {"endpoint-id": 0, "port-index": USER_PORT, "peer": peer}) + ksft.not_ok("userspace-peer-new-dup-eexist", "accepted duplicate") + except NlError as exc: + ksft.check(exc.error == errno.EEXIST, "userspace-peer-new-dup-eexist", + "errno=%d" % exc.error) + # Clear it again so the reject suite leaves the port unlinked. + try: + fab.do("port-peer-del", {"endpoint-id": 0, "port-index": USER_PORT}) + ksft.ok("userspace-peer-del-ok") + except NlError as exc: + ksft.not_ok("userspace-peer-del-ok", "errno=%d" % exc.error) + + +def _reject_incomplete_peer(ksft, cfg, peer, name): + """A port-peer-new with an incomplete peer must be refused with -EINVAL. + Targets the userspace-managed port, so the rejection is unambiguously + peer-attribute validation, not the provider/userspace mode check. + """ + fab, NlError = cfg.fab, cfg.NlError + try: + fab.do("port-peer-new", + {"endpoint-id": 0, "port-index": USER_PORT, "peer": peer}) + ksft.not_ok(name, "accepted incomplete peer") + except NlError as exc: + ksft.check(exc.error == errno.EINVAL, name, "errno=%d" % exc.error) + + +def test_reject_port_peer_new_no_type(ksft, cfg): + """A peer without a type is rejected (no valid peer type is 0).""" + _reject_incomplete_peer(ksft, cfg, {"peer-id": 258, "port-index": 0}, + "reject-port-peer-new-no-type-einval") + + +def test_reject_port_peer_new_no_port_index(ksft, cfg): + """A peer without a port-index is rejected (0 would be a valid index).""" + _reject_incomplete_peer(ksft, cfg, {"peer-id": 258, "type": "accel"}, + "reject-port-peer-new-no-port-index-einval") + + +def test_reject_port_peer_new_no_peer_id(ksft, cfg): + """A peer without a peer-id is rejected.""" + _reject_incomplete_peer(ksft, cfg, {"type": "accel", "port-index": 0}, + "reject-port-peer-new-no-peer-id-einval") + + +def test_reject_fabric_del_provider(ksft, cfg): + """FABRIC_DEL refuses a provider-owned fabric with -EPERM.""" + fab, NlError = cfg.fab, cfg.NlError + prov = [f["fabric"] for f in fab.dump("fabric-get", {}) + if f["fabric"].get("name") == "fabricsim"] + if not prov: + ksft.skip("reject-fabric-del-provider-eperm", "no fabricsim fabric") + return + try: + fab.do("fabric-del", {"fabric-id": prov[0]["fabric-id"]}) + ksft.not_ok("reject-fabric-del-provider-eperm", "accepted") + except NlError as exc: + ksft.check(exc.error == errno.EPERM, "reject-fabric-del-provider-eperm", + "errno=%d" % exc.error) + + +def test_reject_port_peer_del_unlinked(ksft, cfg): + """PORT_PEER_DEL on the unlinked userspace-managed port is -ENOENT.""" + fab, NlError = cfg.fab, cfg.NlError + try: + fab.do("port-peer-del", {"endpoint-id": 0, "port-index": USER_PORT}) + ksft.not_ok("reject-port-peer-del-unlinked-enoent", "accepted") + except NlError as exc: + ksft.check(exc.error == errno.ENOENT, "reject-port-peer-del-unlinked-enoent", + "errno=%d" % exc.error) + + +def test_reject_port_peer_del_provider_managed(ksft, cfg): + """PORT_PEER_DEL on a provider-managed port is refused with -EOPNOTSUPP.""" + fab, NlError = cfg.fab, cfg.NlError + try: + fab.do("port-peer-del", {"endpoint-id": 0, "port-index": 0}) + ksft.not_ok("reject-port-peer-del-provider-managed-eopnotsupp", "accepted") + except NlError as exc: + ksft.check(exc.error == errno.EOPNOTSUPP, + "reject-port-peer-del-provider-managed-eopnotsupp", + "errno=%d" % exc.error) + + +def test_reject_endpoint_set_empty(ksft, cfg): + fab, NlError = cfg.fab, cfg.NlError + try: + fab.do("endpoint-set", {"endpoint-id": 0}) + ksft.not_ok("reject-endpoint-set-empty-einval", "accepted") + except NlError as exc: + ksft.check(exc.error == errno.EINVAL, "reject-endpoint-set-empty-einval", + "errno=%d" % exc.error) + + +CASES = ( + test_cap_fabric_new_privileged, + test_cap_fabric_new_unprivileged, + test_cap_port_set_unprivileged, + test_cap_port_set_privileged, + test_cap_fabric_get_unprivileged, + test_reject_fabric_del_unknown, + test_reject_fabric_del_provider, + test_reject_fabric_new_no_type, + test_reject_port_peer_new_provider_managed, + test_userspace_peer_roundtrip, + test_reject_port_peer_new_no_type, + test_reject_port_peer_new_no_port_index, + test_reject_port_peer_new_no_peer_id, + test_reject_port_peer_del_unlinked, + test_reject_port_peer_del_provider_managed, + test_reject_endpoint_set_empty, +) + +MUTATION_CASES = tuple( + case for case in CASES + if case is not test_cap_fabric_get_unprivileged +) + + +def main(): + ksft = L.Ksft() + _, NlError = L.import_ynl() + + with L.fabricsim(ksft) as fab: + L.run_cases(ksft, Cfg(fab, NlError), + L.select_cases(fab, CASES, MUTATION_CASES)) + ksft.finish() + + +if __name__ == "__main__": + main() diff --git a/tools/testing/selftests/drivers/gpu/drm_fabric/config b/tools/testing/selftests/drivers/gpu/drm_fabric/config index 6eaab8a7d771..f7b38c11a5a9 100644 --- a/tools/testing/selftests/drivers/gpu/drm_fabric/config +++ b/tools/testing/selftests/drivers/gpu/drm_fabric/config @@ -2,6 +2,11 @@ # Kernel config fragment required to run the drm_fabric kselftests. # Merge with: scripts/kconfig/merge_config.sh or make kselftest-merge. CONFIG_NET=y +# netns_abi.py drives the init_net restriction from a non-initial namespace. +# USER_NS lets it model container root; without it the suite still runs, using +# a network namespace alone. +CONFIG_NET_NS=y +CONFIG_USER_NS=y CONFIG_DRM=y CONFIG_DEBUG_FS=y CONFIG_DRM_FABRIC=m diff --git a/tools/testing/selftests/drivers/gpu/drm_fabric/fabric_abi.py b/tools/testing/selftests/drivers/gpu/drm_fabric/fabric_abi.py index 94cc1078a365..92acf47d45aa 100755 --- a/tools/testing/selftests/drivers/gpu/drm_fabric/fabric_abi.py +++ b/tools/testing/selftests/drivers/gpu/drm_fabric/fabric_abi.py @@ -9,6 +9,7 @@ are immune to CLI text changes. Usage: fabric_abi.py [--no-load] (--no-load: modules already loaded) """ +import errno import os import sys @@ -246,6 +247,33 @@ def test_port_change_ntf(ksft, cfg): L.dbg_write("ep1/port1/oper_state", "active") +def test_endpoint_change_ntf(ksft, cfg): + # ENDPOINT_CHANGE_NTF is emitted by an attribute change (endpoint-set), not + # by unregister -- removing a provider emits ENDPOINT_DELETE_NTF instead. + # Toggle a live endpoint's admin state to provoke the change event, then + # restore the original state so later cases are unaffected. + fab, NlError = cfg.fab, cfg.NlError + ep = fab.do("endpoint-get", {"endpoint-id": 0})["endpoint"] + cur = ep.get("admin-state") + target = "down" if cur == "up" else "up" + ev = L.DrmFabric() + ev.ntf_subscribe(L.MCAST_MONITOR) + L.settle(EVT_SETTLE) + try: + fab.do("endpoint-set", {"endpoint-id": 0, "admin-state": target}) + except NlError as exc: + ksft.not_ok("endpoint-change-ntf-notification", + "endpoint-set errno=%d" % exc.error) + return + got = L.wait_ntf(ev, "endpoint-change-ntf", timeout=EVT_DURATION, + match=lambda n: n["msg"]["endpoint"].get("endpoint-id") == 0) + try: + fab.do("endpoint-set", {"endpoint-id": 0, "admin-state": cur}) + except NlError: + pass + ksft.check(got is not None, "endpoint-change-ntf-notification") + + def test_linear_topology(ksft, cfg): """Reload the sim into the linear topology and assert the chain shape. Restores the default mesh K_4 on the way out (even on failure), so @@ -334,6 +362,119 @@ def test_link_down_exact_count(ksft, cfg): L.dbg_write("ep3/port1/inject", "recover_to_active") +def test_stats_survive_mutation(ksft, cfg): + if not cfg.dfs: + ksft.skip("stats-counters-survive-mutation", "debugfs not available") + return + fab, NlError = cfg.fab, cfg.NlError + L.dbg_write("ep2/port0/inject", "link_down") + L.dbg_write("ep2/port0/inject", "link_down") + pre = fab.do("port-stats-get", + {"endpoint-id": 2, "port-index": 0})["port-stats"] + cpre = pre.get("link-down-count", 0) + survived = True + try: + fab.do("port-set", {"endpoint-id": 2, "port-index": 0, + "admin-state": "down"}) + fab.do("port-set", {"endpoint-id": 2, "port-index": 0, + "admin-state": "up"}) + # detach then re-attach the endpoint (mutation on membership). An + # endpoint must be admin-down to leave its fabric (decoupled lifecycle + # invariant), so bring it down first and restore admin-up after. + fab.do("endpoint-set", {"endpoint-id": 2, "admin-state": "down"}) + fab.do("endpoint-set", {"endpoint-id": 2, "fabric-id": 0}) + fab.do("endpoint-set", {"endpoint-id": 2, "fabric-id": cfg.fid}) + fab.do("endpoint-set", {"endpoint-id": 2, "admin-state": "up"}) + except NlError as exc: + # The sim does not ordinarily reject this mutation-only sequence (no + # fault injection is armed here), so an unexpected failure here is a + # real ABI regression, not an environmental limitation. + survived = None + ksft.not_ok("stats-counters-survive-mutation", + "mutation errno=%d" % L.nl_errno(exc)) + if survived is not None: + post = fab.do("port-stats-get", + {"endpoint-id": 2, "port-index": 0})["port-stats"] + ksft.check(post.get("link-down-count", 0) == cpre, + "stats-counters-survive-mutation", + "pre=%d post=%s" % (cpre, post.get("link-down-count"))) + # Restore everything the sequence above can have changed, not just the + # port: a failure part-way through leaves the endpoint detached or + # admin-down, and skipping with that state still in place would silently + # change the topology every later case enumerates. Re-attaching requires + # admin-down first, so drive the full sequence back. + for cmd, req in (("endpoint-set", {"endpoint-id": 2, "admin-state": "down"}), + ("endpoint-set", {"endpoint-id": 2, "fabric-id": cfg.fid}), + ("endpoint-set", {"endpoint-id": 2, "admin-state": "up"}), + ("port-set", {"endpoint-id": 2, "port-index": 0, + "admin-state": "up"})): + try: + fab.do(cmd, req) + except NlError: + pass + try: + L.dbg_write("ep2/port0/inject", "recover_to_active") + except OSError: + pass + # Assert the restore actually took: a silent failure here is exactly what + # would make a later, unrelated case fail instead of this one. + back = fab.do("endpoint-get", {"endpoint-id": 2})["endpoint"] + ksft.check(back.get("fabric-id") == cfg.fid and + back.get("admin-state") == "up", + "stats-mutation-endpoint-restored", + "fabric-id=%s admin-state=%s" + % (back.get("fabric-id"), back.get("admin-state"))) + + +def test_fabric_new_duplicate(ksft, cfg): + fab, NlError = cfg.fab, cfg.NlError + params = {"type": "synthetic", "name": "iid-uniq", "instance-id": 0x9999} + + def fabric_cleanup(fabric_id): + def drop(): + """Delete the fabric unless explicit cleanup already did.""" + try: + fab.do("fabric-del", {"fabric-id": fabric_id}) + except NlError as exc: + if L.nl_errno(exc) != errno.ENOENT: + raise + + return drop + + try: + fabric_id = fab.do("fabric-new", + {"fabric-new-params": params})["fabric-id"] + except NlError as exc: + ksft.not_ok("fabric-new-duplicate-instance-id-eexist", + "setup fabric-new errno=%d" % L.nl_errno(exc)) + return + + L.on_teardown(fabric_cleanup(fabric_id)) + + dup = dict(params, name="iid-dup") + try: + duplicate = fab.do("fabric-new", {"fabric-new-params": dup}) + except NlError as exc: + ksft.check(L.nl_errno(exc) == errno.EEXIST, + "fabric-new-duplicate-instance-id-eexist", + "errno=%d" % L.nl_errno(exc)) + else: + # Arm cleanup before reporting: an accepted duplicate is a second + # live fabric that drop() above cannot reach. + dup_id = duplicate.get("fabric-id") + if dup_id is not None: + L.on_teardown(fabric_cleanup(dup_id)) + ksft.not_ok("fabric-new-duplicate-instance-id-eexist", + "duplicate instance-id accepted") + + try: + fab.do("fabric-del", {"fabric-id": fabric_id}) + ksft.ok("fabric-new-duplicate-cleanup-del") + except NlError as exc: + ksft.not_ok("fabric-new-duplicate-cleanup-del", + "errno=%d" % L.nl_errno(exc)) + + # Ordered scenario: each case builds on the topology/state left by the prior # one (e.g. the linear reload precedes its assertions, and the mesh reload # restores K_N for the stats cases). Keep this list in order. @@ -351,10 +492,21 @@ CASES = ( test_counters_stop, test_port_state_cycle, test_port_change_ntf, + test_endpoint_change_ntf, test_linear_topology, test_reload_mesh, test_port_change_ntf_full, test_link_down_exact_count, + test_stats_survive_mutation, + test_fabric_new_duplicate, +) + +# Cases that exercise the topology-mutation uAPI. On a query-only build the +# family has no mutation ops, so these are filtered out. +MUTATION_CASES = ( + test_endpoint_change_ntf, + test_stats_survive_mutation, + test_fabric_new_duplicate, ) @@ -388,12 +540,12 @@ def main(): except (OSError, NlError) as exc: ksft.skip_all("cannot open drm-fabric family: %s" % exc) - # fabric-id 0 is reserved; discover the live provider fabric id. + # fabric-id 0 is the reserved orphan sentinel; discover the live id. fabrics = fab.dump("fabric-get", {}) fid = fabrics[0]["fabric"]["fabric-id"] if fabrics else 1 cfg = Cfg(fab, fid, L.debugfs_available(), no_load, NlError) - L.run_cases(ksft, cfg, CASES) + L.run_cases(ksft, cfg, L.select_cases(fab, CASES, MUTATION_CASES)) ksft.finish() diff --git a/tools/testing/selftests/drivers/gpu/drm_fabric/fault_abi.py b/tools/testing/selftests/drivers/gpu/drm_fabric/fault_abi.py index 8ea2d1de93d7..fce15ef3ca5c 100755 --- a/tools/testing/selftests/drivers/gpu/drm_fabric/fault_abi.py +++ b/tools/testing/selftests/drivers/gpu/drm_fabric/fault_abi.py @@ -2,10 +2,10 @@ # SPDX-License-Identifier: GPL-2.0 # Copyright (c) 2026 Intel Corporation """ -Provider fault injection via fabricsim's fail_register debugfs toggle (cf. -netdevsim's should_fail): a failed provider-driven endpoint create must -surface the provider's errno and leak no endpoint, succeeding once the -fault is cleared. +Provider fault injection via fabricsim's fail_* debugfs toggles (cf. +netdevsim's should_fail): a failed mutation must surface the provider's +exact errno through genetlink, leave core state untouched, emit no change +notification, and succeed once the fault is cleared. Requires drm_fabric + drm_fabric_sim with fabricsim debugfs; run as root. """ @@ -17,6 +17,12 @@ import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import lib_drm_fabric as L +# Budget for proving a notification did *not* arrive. Short by design: the +# failing command has already returned before the wait starts, so a success +# notification would have been queued by then. +EVT_NEG_DURATION = float(os.environ.get("EVT_NEG_DURATION", "0.5")) +EVT_SETTLE = float(os.environ.get("EVT_SETTLE", "0.2")) + def eps_by_name(fab): return {e["endpoint"]["name"]: e["endpoint"] @@ -51,10 +57,114 @@ def set_fault(name, on): L.dbg_write(name, "Y" if on else "N") +def set_fail_errno(code): + L.dbg_write("fail_errno", int(code)) + + +def has_fail_errno(): + return os.path.exists(os.path.join(L.DEBUGFS, "fail_errno")) + + class Cfg: - def __init__(self, fab, fid): + def __init__(self, fab, nl_error, fid, orphan): self.fab = fab + self.NlError = nl_error self.fid = fid + self.orphan = orphan + self.oid = orphan["endpoint-id"] + self.oslot = slot_of(orphan["name"]) + + +def test_endpoint_set_fault(ksft, cfg): + """A failed ENDPOINT_SET returns -ENOMEM and rolls back; clearing succeeds.""" + fab, NlError = cfg.fab, cfg.NlError + orphan, oid, fid = cfg.orphan, cfg.oid, cfg.fid + + ksft.check(orphan.get("fabric-id", 0) == 0, "fault-orphan-precondition", + "fabric-id=%s" % orphan.get("fabric-id")) + + # A failed mutation must not emit a success notification. + ev = L.DrmFabric() + ev.ntf_subscribe(L.MCAST_MONITOR) + L.settle(EVT_SETTLE) + + set_fault("fail_mutation", True) + try: + got = None + try: + fab.do("endpoint-set", {"endpoint-id": oid, "fabric-id": fid}) + except NlError as exc: + got = exc.error + ksft.check(got == errno.ENOMEM, "fault-endpoint-set-returns-enomem", + "errno=%s" % got) + + ec = L.wait_ntf(ev, "endpoint-change-ntf", timeout=EVT_NEG_DURATION, + match=lambda n: n["msg"]["endpoint"].get("endpoint-id") == oid) + ksft.check(ec is None, "fault-endpoint-set-emits-no-ntf", + "unexpected endpoint-change for %s: %s" % (oid, ec)) + + now = eps_by_name(fab).get(orphan["name"], {}) + ksft.check(now.get("fabric-id", 0) == 0, "fault-endpoint-set-failure-atomicity", + "fabric-id=%s (expected still-orphan)" % now.get("fabric-id")) + finally: + set_fault("fail_mutation", False) + ok = True + try: + fab.do("endpoint-set", {"endpoint-id": oid, "fabric-id": fid}) + except NlError as exc: + ok = False + ksft.not_ok("fault-cleared-endpoint-set-ok", "errno=%d" % exc.error) + if ok: + attached = eps_by_name(fab).get(orphan["name"], {}) + ksft.check(attached.get("fabric-id") == fid, + "fault-cleared-endpoint-set-ok", + "fabric-id=%s" % attached.get("fabric-id")) + ec2 = L.wait_ntf(ev, "endpoint-change-ntf", timeout=EVT_NEG_DURATION, + match=lambda n: n["msg"]["endpoint"].get("endpoint-id") == oid) + ksft.check(ec2 is not None, "fault-cleared-endpoint-set-emits-ntf", + "expected endpoint-change for %s, got none" % oid) + + try: + fab.do("endpoint-set", {"endpoint-id": oid, "fabric-id": 0}) + except NlError: + pass + del_via(fab, orphan["name"], cfg.oslot) + + +def test_port_peer_new_fault(ksft, cfg): + """A failed PORT_PEER_NEW returns -ENOMEM and leaves no peer behind.""" + fab, NlError = cfg.fab, cfg.NlError + ep_a = add_via(fab, "add_endpoint", nports=1) + if ep_a is None: + ksft.not_ok("fault-port-peer-new-returns-enomem", "add ep failed") + ksft.not_ok("fault-port-peer-new-failure-atomicity", "add ep failed") + return + a_id = ep_a["endpoint-id"] + ev = L.DrmFabric() + ev.ntf_subscribe(L.MCAST_MONITOR) + L.settle(EVT_SETTLE) + set_fault("fail_mutation", True) + try: + got = None + try: + fab.do("port-peer-new", + {"endpoint-id": a_id, "port-index": 0, + "peer": {"peer-id": 0xBEEF, "type": "accel", + "port-index": 0}}) + except NlError as exc: + got = exc.error + ksft.check(got == errno.ENOMEM, "fault-port-peer-new-returns-enomem", + "errno=%s" % got) + pc = L.wait_ntf(ev, "port-change-ntf", timeout=EVT_NEG_DURATION, + match=lambda n: n["msg"]["port"].get("endpoint-id") == a_id) + ksft.check(pc is None, "fault-port-peer-new-emits-no-ntf", + "unexpected port-change for %s: %s" % (a_id, pc)) + finally: + set_fault("fail_mutation", False) + pa = fab.do("port-get", {"endpoint-id": a_id, "port-index": 0})["port"] + ksft.check("peer" not in pa, "fault-port-peer-new-failure-atomicity", + "unexpected peer=%s" % pa.get("peer")) + del_via(fab, ep_a["name"], slot_of(ep_a["name"])) def test_register_fault(ksft, cfg): @@ -84,20 +194,107 @@ def test_register_fault(ksft, cfg): del_via(fab, created["name"], slot_of(created["name"])) +def test_errno_round_trip(ksft, cfg): + """A selectable provider errno propagates verbatim (not flattened to ENOMEM).""" + fab, NlError = cfg.fab, cfg.NlError + if not has_fail_errno(): + ksft.skip("fault-errno-round-trip", "fail_errno knob absent (old module)") + return + ep = add_via(fab, "add_endpoint", nports=1) + if ep is None: + ksft.not_ok("fault-errno-round-trip", "add ep failed") + return + a_id = ep["endpoint-id"] + # EBUSY is not the -ENOMEM the other cases use nor a code genl raises itself, + # so seeing it come back means the provider's errno was preserved verbatim. + set_fail_errno(errno.EBUSY) + set_fault("fail_mutation", True) + try: + got = None + try: + fab.do("port-peer-new", + {"endpoint-id": a_id, "port-index": 0, + "peer": {"peer-id": 0xBEEF, "type": "accel", + "port-index": 0}}) + except NlError as exc: + got = exc.error + finally: + set_fault("fail_mutation", False) + set_fail_errno(errno.ENOMEM) # restore the default for later cases + ksft.check(got == errno.EBUSY, "fault-errno-round-trip", + "expected EBUSY(%d), got %s" % (errno.EBUSY, got)) + del_via(fab, ep["name"], slot_of(ep["name"])) + + +def test_port_peer_del_fault(ksft, cfg): + """A failed PORT_PEER_DEL surfaces the errno and keeps the peer (failure atomicity).""" + fab, NlError = cfg.fab, cfg.NlError + ep = add_via(fab, "add_endpoint", nports=1) + if ep is None: + ksft.not_ok("fault-port-peer-del-returns-errno", "add ep failed") + ksft.not_ok("fault-port-peer-del-retained", "add ep failed") + return + a_id = ep["endpoint-id"] + fab.do("port-peer-new", + {"endpoint-id": a_id, "port-index": 0, + "peer": {"peer-id": 0xBEEF, "type": "accel", "port-index": 0}}) + # Subscribe after the successful add, so any event seen below belongs to the + # failing delete rather than the setup. + ev = L.DrmFabric() + ev.ntf_subscribe(L.MCAST_MONITOR) + L.settle(EVT_SETTLE) + set_fault("fail_mutation", True) + try: + got = None + try: + fab.do("port-peer-del", {"endpoint-id": a_id, "port-index": 0}) + except NlError as exc: + got = exc.error + ksft.check(got == errno.ENOMEM, "fault-port-peer-del-returns-errno", + "errno=%s" % got) + pd = L.wait_ntf(ev, "port-change-ntf", timeout=EVT_NEG_DURATION, + match=lambda n: n["msg"]["port"].get("endpoint-id") == a_id) + ksft.check(pd is None, "fault-port-peer-del-emits-no-ntf", + "unexpected port-change for %s: %s" % (a_id, pd)) + pa = fab.do("port-get", {"endpoint-id": a_id, "port-index": 0})["port"] + ksft.check("peer" in pa, "fault-port-peer-del-retained", + "peer unexpectedly removed after failed delete") + finally: + set_fault("fail_mutation", False) + try: + fab.do("port-peer-del", {"endpoint-id": a_id, "port-index": 0}) + except NlError as exc: + ksft.not_ok("fault-port-peer-del-cleared-ok", "errno=%d" % exc.error) + else: + pa = fab.do("port-get", {"endpoint-id": a_id, "port-index": 0})["port"] + ksft.check("peer" not in pa, "fault-port-peer-del-cleared-ok", + "peer still present after clear") + del_via(fab, ep["name"], slot_of(ep["name"])) + + CASES = ( + test_endpoint_set_fault, + test_port_peer_new_fault, test_register_fault, + test_errno_round_trip, + test_port_peer_del_fault, ) def main(): ksft = L.Ksft() + _, NlError = L.import_ynl() - with L.fabricsim(ksft, need_debugfs=True, need_control="fail_register") as fab: + with L.fabricsim(ksft, need_debugfs=True, need_control="fail_mutation") as fab: fid = fabricsim_fid(fab) if fid is None: ksft.skip_all("fabricsim fabric not present") - L.run_cases(ksft, Cfg(fab, fid), CASES) + orphan = add_via(fab, "add_orphan", nports=1) + if orphan is None: + ksft.skip_all("could not create orphan endpoint") + + L.run_cases(ksft, Cfg(fab, NlError, fid, orphan), CASES) ksft.finish() diff --git a/tools/testing/selftests/drivers/gpu/drm_fabric/hotplug_abi.py b/tools/testing/selftests/drivers/gpu/drm_fabric/hotplug_abi.py index 19a3405fade9..df70403a7e30 100755 --- a/tools/testing/selftests/drivers/gpu/drm_fabric/hotplug_abi.py +++ b/tools/testing/selftests/drivers/gpu/drm_fabric/hotplug_abi.py @@ -2,10 +2,11 @@ # SPDX-License-Identifier: GPL-2.0 # Copyright (c) 2026 Intel Corporation """ -Endpoint hotplug via fabricsim's debugfs lifecycle controls (add_endpoint/ -del_endpoint, cf. netdevsim's new_port/del_port): CREATE/DELETE events -observed over the read-only query ABI and notifications; only the hotplug -stimulus uses the debugfs controls. +Endpoint hotplug via fabricsim's debugfs lifecycle controls (cf. netdevsim's +new_port/del_port): CREATE/DELETE events and peer-unplug edge retention are +observed over the real ABI; ENDPOINT_SET/PORT_SET mutation is also issued over +the real (privileged) genetlink ABI -- only the hotplug stimulus itself uses +the test-only debugfs controls. Usage: hotplug_abi.py [--no-load] """ @@ -17,6 +18,10 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import lib_drm_fabric as L EVT_DURATION = float(os.environ.get("EVT_DURATION", "3")) +# Window for asserting an event is ABSENT: a peer unplug emits (or suppresses) +# its notification synchronously during the del, so a short window proves +# non-arrival without burning the full positive EVT_DURATION. +EVT_NEG_DURATION = float(os.environ.get("EVT_NEG_DURATION", "0.5")) # Subscription is synchronous (setsockopt); a brief settle suffices before # triggering, after which wait_ntf() polls with a deadline. EVT_SETTLE = float(os.environ.get("EVT_SETTLE", "0.2")) @@ -59,6 +64,7 @@ class Cfg: self.fid = fid self.NlError = nl_error + def _gen(fab): """Current global topology-generation, read via a stable initial port.""" return fab.do("port-get", @@ -66,9 +72,13 @@ def _gen(fab): def test_provider_topology_lifecycle(ksft, cfg): - """Provider grows and shrinks the topology within its fabric. - - Non-destructive: only the endpoints it adds are removed. + """Provider grows and shrinks the topology within its fabric: a + read-only ABI view of the xGMI-shaped lifecycle where the provider owns + membership/adjacency and userspace only observes. Asserts (a) initial + adjacency visible, (b) hotplug CREATE/DELETE events each advance + topology-generation, (c) a late arrival carries no peer (provider + links explicitly, doesn't auto-wire), (d) pure reads never advance the + generation. Non-destructive: only the two added endpoints are removed. """ fab = cfg.fab @@ -143,9 +153,11 @@ def test_provider_topology_lifecycle(ksft, cfg): "n0=%d now=%d" % (n0, len(eps_by_name(fab)))) - def test_hotplug_lifecycle(ksft, cfg): - """Hotplug one endpoint and unplug it: CREATE, DELETE, membership.""" + """Hotplug one endpoint and unplug it, asserting the CREATE/DELETE + events and membership. Self-contained: adds and deletes the same + endpoint. + """ fab = cfg.fab ev = L.DrmFabric() ev.ntf_subscribe(L.MCAST_MONITOR) @@ -181,9 +193,124 @@ def test_hotplug_lifecycle(ksft, cfg): del_ep(fab, slot_of(name), name) +def test_peer_unplug(ksft, cfg): + """Link two members, delete one, assert the survivor's peer is intact.""" + fab, NlError = cfg.fab, cfg.NlError + ep_a = add_ep(fab, "add_endpoint", nports=1) + ep_b = add_ep(fab, "add_endpoint", nports=1) + if not (ep_a and ep_b): + ksft.not_ok("peer-unplug-link-established", "could not add two endpoints") + ksft.not_ok("peer-unplug-survivor-peer-retained", "setup failed") + ksft.not_ok("peer-unplug-no-port-change-ntf", "setup failed") + # Tear down the half-built setup: an endpoint left behind here joins + # the fabric every later case enumerates, turning one failed setup + # into unrelated failures further down the suite. + for ep in (ep_a, ep_b): + if ep: + del_ep(fab, slot_of(ep["name"]), ep["name"]) + return + + a_id, b_id = ep_a["endpoint-id"], ep_b["endpoint-id"] + a_fepid, b_fepid = ep_a["fabric-ep-id"], ep_b["fabric-ep-id"] + linked = True + try: + fab.do("port-peer-new", {"endpoint-id": a_id, "port-index": 0, + "peer": {"peer-id": b_fepid, + "type": "accel", + "port-index": 0}}) + fab.do("port-peer-new", {"endpoint-id": b_id, "port-index": 0, + "peer": {"peer-id": a_fepid, + "type": "accel", + "port-index": 0}}) + except NlError as exc: + linked = False + ksft.not_ok("peer-unplug-link-setup", "errno=%d" % exc.error) + + if linked: + pa = fab.do("port-get", {"endpoint-id": a_id, "port-index": 0})["port"] + ksft.check("peer" in pa, "peer-unplug-link-established") + + ev = L.DrmFabric() + ev.ntf_subscribe(L.MCAST_MONITOR) + L.settle(EVT_SETTLE) + del_ep(fab, slot_of(ep_b["name"]), ep_b["name"]) + pc = L.wait_ntf( + ev, "port-change-ntf", timeout=EVT_NEG_DURATION, + match=lambda n: n["msg"]["port"].get("endpoint-id") == a_id) + pa2 = fab.do("port-get", + {"endpoint-id": a_id, "port-index": 0})["port"] + ksft.check("peer" in pa2, "peer-unplug-survivor-peer-retained", + "peer=%s" % pa2.get("peer")) + ksft.check(pc is None, "peer-unplug-no-port-change-ntf", + "unexpected port-change for a=%s" % (pc,)) + else: + del_ep(fab, slot_of(ep_b["name"]), ep_b["name"]) + del_ep(fab, slot_of(ep_a["name"]), ep_a["name"]) + + +def test_orphan_lifecycle(ksft, cfg): + """Orphan attach -> admin up/down -> detach, plus a PORT_SET round-trip.""" + fab, NlError = cfg.fab, cfg.NlError + orphan = add_ep(fab, "add_orphan", nports=1) + if not orphan: + ksft.not_ok("endpoint-set-orphan-created", "add_orphan failed") + return + + o_id = orphan["endpoint-id"] + ksft.check(orphan.get("fabric-id", 0) == 0, "endpoint-set-orphan-created", + "fabric-id=%s" % orphan.get("fabric-id")) + + def ep_now(): + return fab.do("endpoint-get", {"endpoint-id": o_id})["endpoint"] + + try: + fab.do("endpoint-set", {"endpoint-id": o_id, "fabric-id": cfg.fid}) + e = ep_now() + ksft.check(e.get("fabric-id") == cfg.fid and + e.get("admin-state") == "down", + "endpoint-set-attach-keeps-admin-down", + "fabric=%s admin=%s" % (e.get("fabric-id"), + e.get("admin-state"))) + + fab.do("endpoint-set", {"endpoint-id": o_id, "admin-state": "up"}) + ksft.check(ep_now().get("admin-state") == "up", + "endpoint-set-admin-up") + + fab.do("endpoint-set", {"endpoint-id": o_id, "admin-state": "down"}) + fab.do("endpoint-set", {"endpoint-id": o_id, "fabric-id": 0}) + ksft.check(ep_now().get("fabric-id", 0) == 0, + "endpoint-set-detach-to-orphan") + except NlError as exc: + ksft.not_ok("endpoint-set-attach-keeps-admin-down", + "errno=%d" % exc.error) + ksft.not_ok("endpoint-set-admin-up", "setup failed") + ksft.not_ok("endpoint-set-detach-to-orphan", "setup failed") + + try: + fab.do("port-set", {"endpoint-id": o_id, "port-index": 0, + "admin-state": "down"}) + d = fab.do("port-get", + {"endpoint-id": o_id, "port-index": 0})["port"] + fab.do("port-set", {"endpoint-id": o_id, "port-index": 0, + "admin-state": "up"}) + u = fab.do("port-get", + {"endpoint-id": o_id, "port-index": 0})["port"] + ksft.check(d.get("admin-state") == "down" and + u.get("admin-state") == "up", + "port-set-admin-round-trip", + "down=%s up=%s" % (d.get("admin-state"), + u.get("admin-state"))) + except NlError as exc: + ksft.not_ok("port-set-admin-round-trip", "errno=%d" % exc.error) + + del_ep(fab, slot_of(orphan["name"]), orphan["name"]) + + CASES = ( test_provider_topology_lifecycle, test_hotplug_lifecycle, + test_peer_unplug, + test_orphan_lifecycle, ) diff --git a/tools/testing/selftests/drivers/gpu/drm_fabric/lib_drm_fabric.py b/tools/testing/selftests/drivers/gpu/drm_fabric/lib_drm_fabric.py index 30fb0edb02b9..9e5dfd6a6ba9 100644 --- a/tools/testing/selftests/drivers/gpu/drm_fabric/lib_drm_fabric.py +++ b/tools/testing/selftests/drivers/gpu/drm_fabric/lib_drm_fabric.py @@ -267,6 +267,16 @@ def family_has_op(fab, name): return name in getattr(fab, "ops", {}) +def select_cases(fab, cases, mutation_cases, probe="fabric-new"): + """Return @cases, dropping @mutation_cases when @probe (a representative + mutation op) is absent from the family. + """ + if family_has_op(fab, probe): + return tuple(cases) + drop = set(mutation_cases) + return tuple(c for c in cases if c not in drop) + + # System helpers (kselftest runs as root) def is_root(): @@ -462,6 +472,13 @@ def fabricsim(ksft, topology=None, need_debugfs=False, need_control=None, if not insmod("drm-fabric.ko") or not insmod("drm-fabric-sim.ko"): ksft.skip_all("could not load drm_fabric + drm_fabric_sim modules") wait_until(lambda: module_loaded("drm_fabric_sim")) + else: + # Running against providers somebody else loaded (--no-load, or a + # previous suite that restored the sim but kept the core). There is no + # module to unwind, but the suite can still add endpoints and peers, + # and without a teardown that state would leak into the next suite and + # survive the timeout killer's SIGTERM. Restore the default shape. + on_teardown(sim_restore_default) if not module_loaded("drm_fabric_sim"): ksft.skip_all("drm_fabric_sim not loaded") diff --git a/tools/testing/selftests/drivers/gpu/drm_fabric/netns_abi.py b/tools/testing/selftests/drivers/gpu/drm_fabric/netns_abi.py new file mode 100755 index 000000000000..ea165e870130 --- /dev/null +++ b/tools/testing/selftests/drivers/gpu/drm_fabric/netns_abi.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-2.0 +# Copyright (c) 2026 Intel Corporation +""" +Confinement is by init_net, not CAP_NET_ADMIN-in-userns: a child that +unshares into its own user+net namespace (or net-only, without +CONFIG_USER_NS) and regains root must still be refused, and specifically +refused *while holding CAP_NET_ADMIN* -- the complement of +cap_netadmin.py's unprivileged-in-init_net case. Verifies the child truly +left init_net and the family still resolves before trusting any -EPERM. + +Requires drm_fabric + drm_fabric_sim; run as root. Skips without user +namespace support. +""" + +import ctypes +import errno +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import lib_drm_fabric as L + +CLONE_NEWUSER = 0x10000000 +CLONE_NEWNET = 0x40000000 +CAP_NET_ADMIN = 12 + + +def _cap_effective(): + """CapEff bitmask of the calling thread, or None if unreadable.""" + try: + with open("/proc/self/status", encoding="ascii") as f: + for line in f: + if line.startswith("CapEff:"): + return int(line.split()[1], 16) + except OSError: + pass + return None + + +def _map_self(uid, gid): + """Map @uid/@gid to 0 in the new user namespace. Ids must be read before + unsharing: an unmapped namespace makes getuid() answer the overflow + uid, and the kernel only accepts a self-map with the caller's real + parent-side id. + """ + try: + # setgroups must be denied before gid_map is writable. + with open("/proc/self/setgroups", "w", encoding="ascii") as f: + f.write("deny") + with open("/proc/self/uid_map", "w", encoding="ascii") as f: + f.write("0 %d 1" % uid) + with open("/proc/self/gid_map", "w", encoding="ascii") as f: + f.write("0 %d 1" % gid) + except OSError as exc: + return "id map: %s" % exc + return None + + +def _enter_namespaces(): + """Enter a non-initial network namespace; returns (mode, failure). + Prefers "user+net" (models container root); falls back to "net" alone + when CONFIG_USER_NS is absent, which is if anything the sharper case + since the caller then keeps the initial CAP_NET_ADMIN, isolating the + namespace check. + """ + libc = ctypes.CDLL(None, use_errno=True) + uid, gid = os.getuid(), os.getgid() + + if libc.unshare(CLONE_NEWUSER | CLONE_NEWNET) == 0: + fail = _map_self(uid, gid) + return (None, fail) if fail else ("user+net", None) + first = os.strerror(ctypes.get_errno()) + + if libc.unshare(CLONE_NEWNET) == 0: + return "net", None + return None, ("user+net: %s; net: %s" + % (first, os.strerror(ctypes.get_errno()))) + + +def _try(fab, NlError, fn): + """Return 'ok' or the positive errno the ABI answered with.""" + try: + fn(fab) + return "ok" + except NlError as exc: + return L.nl_errno(exc) + + +def _child_probe(w): + """Everything measured inside the new namespaces, reported as one JSON blob.""" + out = {"stage": "start"} + try: + mode, fail = _enter_namespaces() + if fail: + out = {"stage": "unshare", "detail": fail} + raise SystemExit + + out = { + "stage": "entered", + "mode": mode, + "ns_inode": os.stat("/proc/self/ns/net").st_ino, + "cap_eff": _cap_effective(), + } + + _, NlError = L.import_ynl() + try: + fab = L.DrmFabric() + except Exception as exc: # noqa: BLE001 + out["family"] = "error: %s" % exc + raise SystemExit + out["family"] = "ok" + + out["fabric_get"] = _try(fab, NlError, + lambda f: f.do("fabric-get", {"fabric-id": 1})) + out["fabric_get_dump"] = _try(fab, NlError, + lambda f: list(f.dump("fabric-get", {}))) + out["fabric_new"] = _try( + fab, NlError, + lambda f: f.do("fabric-new", {"fabric-new-params": { + "type": "synthetic", "name": "netns", "instance-id": 0x4E5}})) + except SystemExit: + pass + except Exception as exc: # noqa: BLE001 + out["stage"] = "exception" + out["detail"] = str(exc) + os.write(w, json.dumps(out).encode()) + + +_PROBE = None + + +def probe(): + """Run the namespaced child once and cache what it reported.""" + global _PROBE + if _PROBE is not None: + return _PROBE + + r, w = os.pipe() + pid = os.fork() + if pid == 0: # child + os.close(r) + try: + _child_probe(w) + finally: + os.close(w) + os._exit(0) + + os.close(w) + buf = b"" + while True: + chunk = os.read(r, 4096) + if not chunk: + break + buf += chunk + os.close(r) + os.waitpid(pid, 0) + + try: + _PROBE = json.loads(buf.decode()) + except ValueError: + _PROBE = {"stage": "no-report"} + return _PROBE + + +class Cfg: + def __init__(self, fab, nl_error): + self.fab = fab + self.NlError = nl_error + self.init_ns = os.stat("/proc/self/ns/net").st_ino + + +def _entered(ksft, cfg, name): + """Common gate: report SKIP or FAIL when the child never got far enough.""" + p = probe() + if p.get("stage") == "unshare": + ksft.skip(name, "cannot create user+net namespace: %s" + % p.get("detail", "?")) + return None + if p.get("stage") != "entered": + ksft.not_ok(name, "child did not reach the namespace: %s" % p) + return None + return p + + +def test_child_left_init_net(ksft, cfg): + """Control: the child must really be in a different network namespace. + Without it, a kernel lacking CONFIG_NET_NS could leave the child in + init_net and every -EPERM below would be vacuous. + """ + p = _entered(ksft, cfg, "netns-child-left-init-net") + if p is None: + return + ksft.check(p["ns_inode"] != cfg.init_ns, "netns-child-left-init-net", + "mode=%s child ns=%s parent ns=%s" + % (p.get("mode"), p["ns_inode"], cfg.init_ns)) + + +def test_child_holds_cap_net_admin(ksft, cfg): + """Control: the child must hold CAP_NET_ADMIN, else the -EPERM + assertions below would just be an ordinary unprivileged rejection, + proving nothing about namespace confinement. + """ + p = _entered(ksft, cfg, "netns-child-holds-cap-net-admin") + if p is None: + return + cap = p.get("cap_eff") + ksft.check(cap is not None and bool(cap & (1 << CAP_NET_ADMIN)), + "netns-child-holds-cap-net-admin", + "mode=%s CapEff=%s" + % (p.get("mode"), "?" if cap is None else "0x%x" % cap)) + + +def test_family_visible_in_child_netns(ksft, cfg): + """Control: the family is netnsok and resolves in the new namespace, + else the errnos below would be genetlink failing to find it, not the + family refusing the caller. + """ + p = _entered(ksft, cfg, "netns-family-resolves") + if p is None: + return + ksft.check(p.get("family") == "ok", "netns-family-resolves", + "family=%s" % p.get("family")) + + +def _expect_eperm(ksft, cfg, key, name): + p = _entered(ksft, cfg, name) + if p is None: + return + if p.get("family") != "ok": + ksft.not_ok(name, "family did not resolve; errno is not meaningful") + return + got = p.get(key) + ksft.check(got == errno.EPERM, name, + "mode=%s result=%s (expected EPERM)" % (p.get("mode"), got)) + + +def test_fabric_get_refused(ksft, cfg): + """A read is refused too: confinement is not limited to mutation.""" + _expect_eperm(ksft, cfg, "fabric_get", "netns-fabric-get-eperm") + + +def test_fabric_get_dump_refused(ksft, cfg): + """Dumps take the same check as doit handlers.""" + _expect_eperm(ksft, cfg, "fabric_get_dump", "netns-fabric-get-dump-eperm") + + +def test_fabric_new_refused(ksft, cfg): + """Provisioning is refused despite the child holding CAP_NET_ADMIN.""" + _expect_eperm(ksft, cfg, "fabric_new", "netns-fabric-new-eperm") + + +def test_init_net_topology_unchanged(ksft, cfg): + """The refused child must not have created anything in init_net.""" + fab, NlError = cfg.fab, cfg.NlError + try: + names = [f["fabric"].get("name") for f in fab.dump("fabric-get", {})] + except NlError as exc: + ksft.not_ok("netns-init-net-unchanged", "errno=%d" % L.nl_errno(exc)) + return + ksft.check("netns" not in names, "netns-init-net-unchanged", + "fabrics=%s" % names) + + +CASES = ( + test_child_left_init_net, + test_child_holds_cap_net_admin, + test_family_visible_in_child_netns, + test_fabric_get_refused, + test_fabric_get_dump_refused, + test_fabric_new_refused, + test_init_net_topology_unchanged, +) + +# Only the provisioning case needs a mutation-capable build; confinement of +# reads and dumps is a query-only contract asserted on either build. +MUTATION_CASES = ( + test_fabric_new_refused, +) + + +def main(): + ksft = L.Ksft() + _, NlError = L.import_ynl() + + with L.fabricsim(ksft) as fab: + L.run_cases(ksft, Cfg(fab, NlError), + L.select_cases(fab, CASES, MUTATION_CASES)) + ksft.finish() + + +if __name__ == "__main__": + main() diff --git a/tools/testing/selftests/drivers/gpu/drm_fabric/nl_policy_probe.py b/tools/testing/selftests/drivers/gpu/drm_fabric/nl_policy_probe.py index 0d4d60d45e5a..16afa185e87b 100755 --- a/tools/testing/selftests/drivers/gpu/drm_fabric/nl_policy_probe.py +++ b/tools/testing/selftests/drivers/gpu/drm_fabric/nl_policy_probe.py @@ -7,9 +7,6 @@ attrs (wrong type, unknown id, truncated nest, out-of-range enum, missing required) must return a clean NLMSG_ERROR, never an oops; a liveness dump confirms nothing wedged the family. Also introspects the family and emits TAP. - -Topology-mutation policy probes arrive with the provisioning ABI; this -query-only build defines no mutation commands or attributes to probe. """ import errno @@ -116,34 +113,77 @@ def _parse_all_enums(text): return out +# Symbols the uAPI defines on every build. Their absence means the header did +# not parse or is not drm_fabric's, which is distinct from a query-only build +# and must not be confused with one. +_REQUIRED_SYMS = ("DRM_FABRIC_CMD_FABRIC_GET", "DRM_FABRIC_CMD_PORT_GET", + "DRM_FABRIC_A_FABRIC_ID", "DRM_FABRIC_A_ENDPOINT_ID", + "DRM_FABRIC_A_PORT_INDEX", "DRM_FABRIC_A_PEER", + "DRM_FABRIC_A_PEER_ATTRS_PEER_ID", + "DRM_FABRIC_A_PEER_ATTRS_TYPE", "__DRM_FABRIC_A_MAX") + +# Commands that exist only once topology provisioning is present. Whether the +# header defines them describes the build, which is what lets a command missing +# from the live family be reported as a failure instead of a skip. +_MUTATION_CMDS = ("DRM_FABRIC_CMD_FABRIC_NEW", "DRM_FABRIC_CMD_FABRIC_DEL", + "DRM_FABRIC_CMD_ENDPOINT_SET", "DRM_FABRIC_CMD_PORT_SET", + "DRM_FABRIC_CMD_PORT_PEER_NEW", + "DRM_FABRIC_CMD_PORT_PEER_DEL") +_MUTATION_SYMS = _MUTATION_CMDS + ("DRM_FABRIC_A_ADMIN_STATE", + "DRM_FABRIC_A_FABRIC_NEW_PARAMS", + "DRM_FABRIC_A_FABRIC_NEW_PARAMS_TYPE") + + def _load_ids(): - # Committed fallbacks (kept in sync with drm_fabric.h, query-only build). - syms = {"DRM_FABRIC_CMD_FABRIC_GET": 1, "DRM_FABRIC_CMD_PORT_GET": 3, - "DRM_FABRIC_A_FABRIC_ID": 5, "DRM_FABRIC_A_ENDPOINT_ID": 6, - "DRM_FABRIC_A_PORT_INDEX": 7, "DRM_FABRIC_A_PEER": 10} - src = "fallback literals" + """Resolve ids from the uAPI header, or report why we cannot; returns + (ids, header path) or (None, reason). Deliberately no built-in fallback + table: a stale entry wouldn't fail loudly, it would probe the wrong + attribute and still report success. + """ hdr = _find_uapi_header() - if hdr: - parsed = _parse_all_enums(open(hdr).read()) - if "DRM_FABRIC_CMD_PORT_GET" in parsed and "DRM_FABRIC_A_FABRIC_ID" in parsed: - syms, src = parsed, hdr - return syms, src + if not hdr: + return None, ("drm_fabric uAPI header not found; set " + "UAPI_HEADER=/path/to/include/uapi/drm/drm_fabric.h") + syms = _parse_all_enums(open(hdr).read()) + missing = [s for s in _REQUIRED_SYMS if s not in syms] + if missing: + return None, "%s does not define %s" % (hdr, ", ".join(missing)) + return syms, hdr + + +_IDS, _ID_SRC = _load_ids() -_SYMS, _ID_SRC = _load_ids() +def _id(name): + """Value of @name, or None when this build's header does not define it.""" + return _IDS.get(name) if _IDS else None -CMD_FABRIC_GET = _SYMS["DRM_FABRIC_CMD_FABRIC_GET"] -CMD_PORT_GET = _SYMS["DRM_FABRIC_CMD_PORT_GET"] -A_FABRIC_ID = _SYMS["DRM_FABRIC_A_FABRIC_ID"] -A_ENDPOINT_ID = _SYMS["DRM_FABRIC_A_ENDPOINT_ID"] -A_PORT_INDEX = _SYMS["DRM_FABRIC_A_PORT_INDEX"] +CMD_FABRIC_GET = _id("DRM_FABRIC_CMD_FABRIC_GET") +CMD_PORT_GET = _id("DRM_FABRIC_CMD_PORT_GET") +CMD_PORT_SET = _id("DRM_FABRIC_CMD_PORT_SET") +CMD_PORT_PEER_NEW = _id("DRM_FABRIC_CMD_PORT_PEER_NEW") +CMD_FABRIC_NEW = _id("DRM_FABRIC_CMD_FABRIC_NEW") -# An attribute id guaranteed to be past the family's top-level maxattr, so the -# kernel strict-rejects it. Derived from the parsed ids (one past the largest -# symbol) rather than a magic literal, which would silently stop testing strict -# rejection once the attribute set grows past it. -A_UNKNOWN = max(_SYMS.values()) + 1 +A_FABRIC_ID = _id("DRM_FABRIC_A_FABRIC_ID") +A_ENDPOINT_ID = _id("DRM_FABRIC_A_ENDPOINT_ID") +A_PORT_INDEX = _id("DRM_FABRIC_A_PORT_INDEX") +A_ADMIN_STATE = _id("DRM_FABRIC_A_ADMIN_STATE") +A_PEER = _id("DRM_FABRIC_A_PEER") +A_FABRIC_NEW_PARAMS = _id("DRM_FABRIC_A_FABRIC_NEW_PARAMS") + +A_PEER_PEER_ID = _id("DRM_FABRIC_A_PEER_ATTRS_PEER_ID") +A_PEER_TYPE = _id("DRM_FABRIC_A_PEER_ATTRS_TYPE") +A_FABRIC_NEW_PARAMS_TYPE = _id("DRM_FABRIC_A_FABRIC_NEW_PARAMS_TYPE") + +# One past the top-level attribute set's upper bound, so every command +# strict-rejects it: no per-command maxattr can exceed the set it indexes. +# __DRM_FABRIC_A_MAX is that value by construction, so this tracks the set as +# it grows instead of quietly aliasing a real attribute once it does. +A_UNKNOWN = _id("__DRM_FABRIC_A_MAX") + +# What the build supports, as opposed to what the running family advertises. +BUILD_HAS_MUTATION = bool(_IDS) and all(s in _IDS for s in _MUTATION_SYMS) # NLA builders @@ -158,6 +198,17 @@ def nla(attr_type, payload): return struct.pack("=HH", length, attr_type) + payload + pad +def nla_nest(attr_type, payload): + """Build a nest the way a real client does. + + Strict validation rejects an attribute the policy declares as a nest + unless NLA_F_NESTED is set, before it ever recurses into the nested + policy. Without the flag a probe aimed at a nested member only ever + reaches the outer parse. + """ + return nla(attr_type | NLA_F_NESTED, payload) + + def nla_u32(attr_type, val): return nla(attr_type, struct.pack("=I", val & 0xFFFFFFFF)) @@ -173,6 +224,16 @@ def build_msg(family_id, cmd, seq, payload, flags=NLM_F_REQUEST | NLM_F_ACK): return nlh + body +# One counter for every request the suite sends, so each reply can be matched +# to the request that caused it and no two requests ever share a sequence. +_SEQ = [100] + + +def _next_seq(): + _SEQ[0] += 1 + return _SEQ[0] + + # Socket helpers def open_sock(): @@ -234,17 +295,32 @@ def drain(sock, first_timeout=0.5, more_timeout=0.3): return msgs +def _getfamily(sock, name): + """Send one CTRL_CMD_GETFAMILY and return the datagram that answers it. + Only a reply matching our own sequence is accepted: an earlier request's + queued ACK or late reply would otherwise look like a family that + advertises nothing, silently disabling every introspection check. + """ + seq = _next_seq() + sock.send(build_msg(GENL_ID_CTRL, CTRL_CMD_GETFAMILY, seq, + nla(CTRL_ATTR_FAMILY_NAME, name + b"\x00"), + flags=NLM_F_REQUEST)) + while True: + try: + data = sock.recv(65536) + except socket.timeout: + return None + (_, mtype, _, mseq, _) = struct.unpack_from("=IHHII", data, 0) + if mseq != seq: + continue + if mtype == NLMSG_ERROR: + return None + return data + + def resolve_family(sock, name): - seq = 1 - msg = build_msg(GENL_ID_CTRL, CTRL_CMD_GETFAMILY, seq, - nla(CTRL_ATTR_FAMILY_NAME, name + b"\x00")) - sock.send(msg) - try: - data = sock.recv(8192) - except socket.timeout: - return None - (_, mtype, _, _, _) = struct.unpack_from("=IHHII", data, 0) - if mtype == NLMSG_ERROR: + data = _getfamily(sock, name) + if data is None: return None attrs = data[NLMSG_HDRLEN + GENL_HDRLEN:] for atype, payload in iter_attrs(attrs): @@ -263,17 +339,8 @@ def get_family_info(sock, name): letting callers confirm version, admin-perm on mutators, and the monitor group. """ - seq = 2 - msg = build_msg(GENL_ID_CTRL, CTRL_CMD_GETFAMILY, seq, - nla(CTRL_ATTR_FAMILY_NAME, name + b"\x00"), - flags=NLM_F_REQUEST) - sock.send(msg) - try: - data = sock.recv(65536) - except socket.timeout: - return None - (_, mtype, _, _, _) = struct.unpack_from("=IHHII", data, 0) - if mtype == NLMSG_ERROR: + data = _getfamily(sock, name) + if data is None: return None info = {"version": None, "ops": {}, "mcast": set()} @@ -306,17 +373,13 @@ def get_family_info(sock, name): # dynamic plan printed at finish() instead of a hard-coded count that drifts # every time a case is added or removed. -_SEQ = [100] - - def case_rejected(tap, name, sock, fid, cmd, payload, expect): """Pass iff the kernel rejected with one of @expect (positive errno values; the netlink error is negative, so we compare -e). The specific code matters: e.g. -EINVAL for a malformed attribute, not a generic failure. """ - _SEQ[0] += 1 - sock.send(build_msg(fid, cmd, _SEQ[0], payload)) + sock.send(build_msg(fid, cmd, _next_seq(), payload)) msgs = drain(sock) rejected = [-e for (t, e) in msgs if t == NLMSG_ERROR and e is not None and e != 0] @@ -364,12 +427,22 @@ class Cfg: def __init__(self, sock, fid): self.sock = sock self.fid = fid + # Whether the running family advertises the mutation commands, from + # live introspection in main(): True, False, or None when the + # introspection itself failed. The three states are kept apart because + # "this build has no mutation commands" is a skip while "this build has + # them but the family does not offer them" is a failure. + self.live_mutation = None def test_malformed_requests(ksft, cfg): sock, fid = cfg.sock, cfg.fid # Malformed framing/attributes must fail validation with -EINVAL. EINVAL = {errno.EINVAL} + # Out-of-range enums are caught by the generated NLA_POLICY range checks, + # which report -ERANGE and nothing else. Accepting -EINVAL as well would + # let a malformed probe that never reaches the range check pass silently. + ERANGE = {errno.ERANGE} case_rejected(ksft, "wrong-type-short-u32", sock, fid, CMD_FABRIC_GET, nla(A_FABRIC_ID, struct.pack("=H", 1)), EINVAL) @@ -377,10 +450,79 @@ def test_malformed_requests(ksft, cfg): case_rejected(ksft, "unknown-attribute-id", sock, fid, CMD_FABRIC_GET, nla_u32(A_FABRIC_ID, 1) + nla_u32(A_UNKNOWN, 0), EINVAL) + # Policy errors are unreachable when mutation commands are absent. + if cfg.live_mutation: + # Truncated nest: PEER header claims 64 bytes but carries 4. Rejected + # while walking the attributes, before any policy runs. + bad_nest = (struct.pack("=HH", 64, A_PEER | NLA_F_NESTED) + + b"\x00\x00\x00\x00") + case_rejected(ksft, "truncated-nest", sock, fid, CMD_PORT_PEER_NEW, + nla_u32(A_ENDPOINT_ID, 0) + nla_u32(A_PORT_INDEX, 0) + bad_nest, + EINVAL) + + # Out-of-range enum: admin-state past DRM_FABRIC_ADMIN_UP. + case_rejected(ksft, "enum-range-admin-state", sock, fid, CMD_PORT_SET, + nla_u32(A_ENDPOINT_ID, 0) + nla_u32(A_PORT_INDEX, 0) + + nla_u32(A_ADMIN_STATE, 0xFFFFFFFF), ERANGE) + + # Out-of-range enum: peer-type past DRM_FABRIC_PEER_SWITCH, inside a + # nest, so this only reaches the nested policy as a well-formed nest. + peer = nla_u64(A_PEER_PEER_ID, 0x1) + nla_u32(A_PEER_TYPE, 99) + case_rejected(ksft, "enum-range-peer-type", sock, fid, CMD_PORT_PEER_NEW, + nla_u32(A_ENDPOINT_ID, 0) + nla_u32(A_PORT_INDEX, 0) + + nla_nest(A_PEER, peer), ERANGE) + + # Zero fabric-type, which the enum starts above and so never names. + # The range check runs before the doit, so the refusal predates any + # fabric the request could have created, which the next case asserts. + before = fabric_count(sock, fid) + case_rejected(ksft, "enum-range-fabric-type", sock, fid, CMD_FABRIC_NEW, + nla_nest(A_FABRIC_NEW_PARAMS, + nla_u32(A_FABRIC_NEW_PARAMS_TYPE, 0)), + ERANGE) + after = fabric_count(sock, fid) + ksft.check(before is not None and after == before, + "enum-range-fabric-type-not-created", + "fabrics before=%s after=%s" % (before, after)) + else: + # The case set stays the same either way -- the probes are reported + # rather than silently omitted -- but only a query-only build earns a + # skip. If this build defines the mutation commands and the family does + # not offer them, the probes are unrunnable for a reason worth seeing. + if cfg.live_mutation is None: + report, why = ksft.not_ok, ("family introspection failed; cannot " + "tell which commands are advertised") + elif BUILD_HAS_MUTATION: + report, why = ksft.not_ok, ("uAPI header defines the mutation " + "commands but the family advertises " + "none") + else: + report, why = ksft.skip, ("query-only build: uAPI header defines " + "no mutation commands") + for nm in ("truncated-nest", "enum-range-admin-state", + "enum-range-peer-type", "enum-range-fabric-type", + "enum-range-fabric-type-not-created"): + report(nm, why) + case_rejected(ksft, "missing-required-port-index", sock, fid, CMD_PORT_GET, nla_u32(A_ENDPOINT_ID, 0), EINVAL) +def fabric_count(sock, fid): + """Fabrics a dump reports, or None when the dump itself did not succeed. + + None is distinct from zero on purpose: a dump that errored says nothing + about how many fabrics exist, and reporting it as zero would let a broken + dump satisfy a claim that nothing was created. + """ + sock.send(build_msg(fid, CMD_FABRIC_GET, _next_seq(), b"", + flags=NLM_F_REQUEST | NLM_F_DUMP)) + msgs = drain(sock) + if not msgs or any(t == NLMSG_ERROR and e != 0 for (t, e) in msgs): + return None + return sum(1 for (t, _) in msgs if t not in (NLMSG_ERROR, NLMSG_DONE)) + + def test_liveness(ksft, cfg): """A dump that doesn't hang or error is not enough: it must also carry a well-formed, zero-status terminal NLMSG_DONE, or a wedge/regression in @@ -388,8 +530,7 @@ def test_liveness(ksft, cfg): (no data records, just a clean DONE) is still a pass. """ sock, fid = cfg.sock, cfg.fid - _SEQ[0] += 1 - sock.send(build_msg(fid, CMD_FABRIC_GET, _SEQ[0], b"", + sock.send(build_msg(fid, CMD_FABRIC_GET, _next_seq(), b"", flags=NLM_F_REQUEST | NLM_F_DUMP)) msgs = drain(sock) errs = [e for (t, e) in msgs if t == NLMSG_ERROR and e != 0] @@ -412,15 +553,16 @@ def test_liveness(ksft, cfg): def test_family_introspection(ksft, cfg): """Via CTRL_CMD_GETFAMILY: version, admin-perm gating, mcast surface.""" - getter_ids = [_SYMS[n] for n in ( + mutator_ids = [_id(n) for n in _MUTATION_CMDS if _id(n) is not None] + getter_ids = [_id(n) for n in ( "DRM_FABRIC_CMD_FABRIC_GET", "DRM_FABRIC_CMD_ENDPOINT_GET", "DRM_FABRIC_CMD_PORT_GET", "DRM_FABRIC_CMD_PORT_STATS_GET") - if n in _SYMS] + if _id(n) is not None] info = get_family_info(cfg.sock, FAMILY_NAME) if not info: for nm in ("genl-family-version", "genl-mcast-monitor-present", - "genl-getters-not-admin-perm"): + "genl-mutators-admin-perm", "genl-getters-not-admin-perm"): ksft.not_ok(nm, "CTRL_CMD_GETFAMILY introspection failed") return @@ -437,8 +579,19 @@ def test_family_introspection(ksft, cfg): "groups=%s" % info["mcast"]) ops = info["ops"] - # A query-only build exposes getters only: each must be ungated (no - # GENL_ADMIN_PERM), so a normal namespace can enumerate topology. + # The mutator admin-perm gate only applies once the mutation commands exist + # at all; a query-only build registers no mutators to check. Gate on the + # build rather than on the live family, so a build that should advertise + # mutators but does not fails here instead of dropping the check. + if BUILD_HAS_MUTATION: + seen_mut = [c for c in mutator_ids if c in ops] + bad_mut = [c for c in seen_mut if not (ops[c] & GENL_ADMIN_PERM)] + if seen_mut and not bad_mut: + ksft.ok("genl-mutators-admin-perm (%d cmds)" % len(seen_mut)) + else: + ksft.not_ok("genl-mutators-admin-perm", + "seen=%s missing-perm=%s" % (seen_mut, bad_mut)) + seen_get = [c for c in getter_ids if c in ops] bad_get = [c for c in seen_get if ops[c] & GENL_ADMIN_PERM] if seen_get and not bad_get: @@ -461,6 +614,11 @@ def main(): if os.geteuid() != 0: tap.skip_all("root is required to load drm_fabric modules") + # Every probe below is built from uAPI ids, so without them there is + # nothing trustworthy to send. + if _IDS is None: + tap.skip_all(_ID_SRC) + if _maybe_load_modules(): L.on_teardown(_unload_providers) @@ -476,7 +634,15 @@ def main(): sys.stderr.write("# attribute/command ids from: %s\n" % _ID_SRC) + # Ask the live family which of the topology-mutation commands it actually + # offers. Left as None when the introspection fails, so the probes gated on + # it report that rather than treating an unanswered question as a no. cfg = Cfg(sock, fid) + info = get_family_info(sock, FAMILY_NAME) + if info is not None: + cfg.live_mutation = any(_id(n) in info["ops"] for n in _MUTATION_CMDS + if _id(n) is not None) + L.run_cases(tap, cfg, CASES) tap.finish() diff --git a/tools/testing/selftests/drivers/gpu/drm_fabric/provisioning_scenarios_abi.py b/tools/testing/selftests/drivers/gpu/drm_fabric/provisioning_scenarios_abi.py new file mode 100755 index 000000000000..21da97f63615 --- /dev/null +++ b/tools/testing/selftests/drivers/gpu/drm_fabric/provisioning_scenarios_abi.py @@ -0,0 +1,324 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-2.0 +# Copyright (c) 2026 Intel Corporation +""" +End-to-end provisioning lifecycles, tying the implementation to the +intended flows rather than the isolated mechanics covered elsewhere +(cap_netadmin/fault/fabric_abi): orchestrated startup and link +failure/recovery, each detailed on its own test. + +Mutation via the real ABI; operational/telemetry state via fabricsim +debugfs. Needs drm_fabric + drm_fabric_sim (default mesh, 4 ports); root. + +Usage: provisioning_scenarios_abi.py [--no-load] +""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import lib_drm_fabric as L + +EVT_DURATION = float(os.environ.get("EVT_DURATION", "3")) +EVT_SETTLE = float(os.environ.get("EVT_SETTLE", "0.2")) + +USER_PORT = 3 + + +def eps_by_name(fab): + return {e["endpoint"]["name"]: e["endpoint"] + for e in fab.dump("endpoint-get", {})} + + +def fabricsim_fid(fab): + for f in fab.dump("fabric-get", {}): + if f["fabric"]["name"] == "fabricsim": + return f["fabric"]["fabric-id"] + return None + + +def slot_of(name): + return int(name.rsplit("ep", 1)[1]) + + +def add_orphan(fab, nports): + """Register a provider orphan via debugfs; return the new endpoint dict.""" + before = set(eps_by_name(fab)) + L.dbg_write("add_orphan", nports) + new = L.wait_until(lambda: set(eps_by_name(fab)) - before) + if len(new) != 1: + return None + return eps_by_name(fab)[next(iter(new))] + + +def del_ep(fab, slot, name): + L.dbg_write("del_endpoint", slot) + return L.wait_until(lambda: name not in eps_by_name(fab)) + + +class Cfg: + def __init__(self, fab, fid, nl_error): + self.fab = fab + self.fid = fid + self.NlError = nl_error + + +def _gen(fab, ep_id, port_index): + return fab.do("port-get", {"endpoint-id": ep_id, + "port-index": port_index}).get( + "topology-generation") + + +def _port(fab, ep_id, port_index): + return fab.do("port-get", {"endpoint-id": ep_id, + "port-index": port_index})["port"] + + +def _ep(fab, ep_id): + return fab.do("endpoint-get", {"endpoint-id": ep_id})["endpoint"] + + +def test_orchestrated_startup(ksft, cfg): + """Full orchestrated bring-up of a provider-supplied orphan: orphan -> + create fabric -> attach -> endpoint admin up -> port admin up -> + provider oper ACTIVE -> userspace installs a peer. Administrative + intent (userspace) and operational state (provider) move + independently. + """ + fab, NlError = cfg.fab, cfg.NlError + + orphan = add_orphan(fab, nports=4) + if not orphan: + for name in ("startup-orphan-visible", "startup-fabric-created", + "startup-attach-membership", + "startup-attach-endpoint-change-ntf", + "startup-endpoint-admin-up", + "startup-oper-independent-of-admin", + "startup-port-admin-up", + "startup-provider-reports-oper-active", + "startup-oper-active-port-change-ntf", + "startup-userspace-peer-installed"): + ksft.not_ok(name, "add_orphan failed") + return + + o_id = orphan["endpoint-id"] + slot = slot_of(orphan["name"]) + made_fabric = None + + ksft.check(orphan.get("fabric-id", 0) == 0 and + orphan.get("admin-state") == "down", "startup-orphan-visible", + "fabric-id=%s admin=%s" % (orphan.get("fabric-id"), + orphan.get("admin-state"))) + try: + rep = fab.do("fabric-new", {"fabric-new-params": { + "type": "synthetic", "name": "startup", "instance-id": 0x57A}}) + made_fabric = rep.get("fabric-id") + ksft.check(made_fabric is not None, "startup-fabric-created", + "reply=%s" % rep) + + ev = L.DrmFabric() + ev.ntf_subscribe(L.MCAST_MONITOR) + L.settle(EVT_SETTLE) + fab.do("endpoint-set", {"endpoint-id": o_id, "fabric-id": made_fabric}) + attach_ntf = L.wait_ntf( + ev, "endpoint-change-ntf", timeout=EVT_DURATION, + match=lambda n: n["msg"]["endpoint"].get("endpoint-id") == o_id) + e = _ep(fab, o_id) + ksft.check(e.get("fabric-id") == made_fabric and + e.get("admin-state") == "down", "startup-attach-membership", + "fabric=%s admin=%s" % (e.get("fabric-id"), + e.get("admin-state"))) + ksft.check(attach_ntf is not None, + "startup-attach-endpoint-change-ntf") + + fab.do("endpoint-set", {"endpoint-id": o_id, "admin-state": "up"}) + ksft.check(_ep(fab, o_id).get("admin-state") == "up", + "startup-endpoint-admin-up") + + # Bring a provider-managed port admin-up; operational state must not + # follow automatically -- the provider owns it. + pre = _port(fab, o_id, 0) + fab.do("port-set", {"endpoint-id": o_id, "port-index": 0, + "admin-state": "up"}) + p = _port(fab, o_id, 0) + ksft.check(p.get("admin-state") == "up", "startup-port-admin-up") + ksft.check(pre.get("oper-state") != "active" and + p.get("oper-state") != "active", + "startup-oper-independent-of-admin", + "oper=%s" % p.get("oper-state")) + + evp = L.DrmFabric() + evp.ntf_subscribe(L.MCAST_MONITOR) + L.settle(EVT_SETTLE) + L.dbg_write("ep%d/port0/oper_state" % slot, "active") + oper_ntf = L.wait_ntf( + evp, "port-change-ntf", timeout=EVT_DURATION, + match=lambda n: n["msg"]["port"].get("endpoint-id") == o_id) + L.wait_until(lambda: _port(fab, o_id, 0).get("oper-state") == "active") + ksft.check(_port(fab, o_id, 0).get("oper-state") == "active", + "startup-provider-reports-oper-active") + ksft.check(oper_ntf is not None, "startup-oper-active-port-change-ntf") + + peer = {"peer-id": 0x2A, "type": "accel", "port-index": 0} + fab.do("port-peer-new", {"endpoint-id": o_id, "port-index": USER_PORT, + "peer": peer}) + pu = _port(fab, o_id, USER_PORT) + ksft.check(pu.get("peer") is not None and + pu["peer"].get("peer-id") == 0x2A, + "startup-userspace-peer-installed", + "peer=%s" % pu.get("peer")) + finally: + for method, vals in ( + ("port-peer-del", {"endpoint-id": o_id, + "port-index": USER_PORT}), + ("port-set", {"endpoint-id": o_id, "port-index": 0, + "admin-state": "down"}), + ("endpoint-set", {"endpoint-id": o_id, "admin-state": "down"}), + ("endpoint-set", {"endpoint-id": o_id, "fabric-id": 0})): + try: + fab.do(method, vals) + except NlError: + pass + if made_fabric is not None: + try: + fab.do("fabric-del", {"fabric-id": made_fabric}) + except NlError: + pass + del_ep(fab, slot, orphan["name"]) + + +def test_link_failure_and_recovery(ksft, cfg): + """A live link fails and recovers under provider control. + + Uses an initial mesh member (provider-managed port 0 with an established + peer, userspace-managed port 3). Asserts administrative intent survives an + operational failure, telemetry advances without touching topology- + generation, operational transitions do advance it and emit port-change, + an identical admin request is a no-op, and a userspace peer is replaced + with strict delete-before-new ordering leaving no stale descriptor. + """ + fab, NlError = cfg.fab, cfg.NlError + EP, PP, UP = 0, 0, USER_PORT + + fab.do("port-set", {"endpoint-id": EP, "port-index": PP, + "admin-state": "up"}) + L.dbg_write("ep%d/port%d/oper_state" % (EP, PP), "active") + L.wait_until(lambda: _port(fab, EP, PP).get("oper-state") == "active") + try: + base = fab.do("port-stats-get", {"endpoint-id": EP, + "port-index": PP})["port-stats"] + c0 = base.get("link-down-count", 0) + g_active = _gen(fab, EP, PP) + + # Failure: the provider reports the link down. + ev = L.DrmFabric() + ev.ntf_subscribe(L.MCAST_MONITOR) + L.settle(EVT_SETTLE) + L.dbg_write("ep%d/port%d/inject" % (EP, PP), "link_down") + down_ntf = L.wait_ntf( + ev, "port-change-ntf", timeout=EVT_DURATION, + match=lambda n: n["msg"]["port"].get("endpoint-id") == EP) + L.wait_until(lambda: _port(fab, EP, PP).get("oper-state") == "inactive") + p_down = _port(fab, EP, PP) + ksft.check(p_down.get("oper-state") == "inactive", + "linkfail-oper-inactive", "oper=%s" % p_down.get( + "oper-state")) + ksft.check(p_down.get("admin-state") == "up", + "linkfail-admin-stays-up", "admin=%s" % p_down.get( + "admin-state")) + ksft.check(down_ntf is not None, "linkfail-oper-change-port-change-ntf") + g_down = _gen(fab, EP, PP) + ksft.check(g_active is not None and g_down is not None and + g_down > g_active, "linkfail-oper-change-advances-generation", + "active=%s down=%s" % (g_active, g_down)) + + # Telemetry advances; a stats read must not advance topology-generation. + s = fab.do("port-stats-get", {"endpoint-id": EP, + "port-index": PP})["port-stats"] + ksft.check(s.get("link-down-count", 0) >= c0 + 1, + "linkfail-link-down-count-increases", + "c0=%d now=%s" % (c0, s.get("link-down-count"))) + ksft.check(_gen(fab, EP, PP) == g_down, + "linkfail-stats-read-no-generation-bump") + + # Recovery. + ev2 = L.DrmFabric() + ev2.ntf_subscribe(L.MCAST_MONITOR) + L.settle(EVT_SETTLE) + L.dbg_write("ep%d/port%d/inject" % (EP, PP), "recover_to_active") + up_ntf = L.wait_ntf( + ev2, "port-change-ntf", timeout=EVT_DURATION, + match=lambda n: n["msg"]["port"].get("endpoint-id") == EP) + L.wait_until(lambda: _port(fab, EP, PP).get("oper-state") == "active") + ksft.check(_port(fab, EP, PP).get("oper-state") == "active", + "linkfail-recovery-oper-active") + g_recovered = _gen(fab, EP, PP) + ksft.check(g_recovered > g_down, + "linkfail-recovery-advances-generation", + "down=%s recovered=%s" % (g_down, g_recovered)) + ksft.check(up_ntf is not None, "linkfail-recovery-port-change-ntf") + + # An identical admin request is a no-op: no generation change. + g_pre_noop = _gen(fab, EP, PP) + fab.do("port-set", {"endpoint-id": EP, "port-index": PP, + "admin-state": "up"}) + ksft.check(_gen(fab, EP, PP) == g_pre_noop, + "linkfail-idempotent-admin-noop") + + # Peer replacement on the userspace-managed port: X, then delete, then + # Y -- strict delete-before-new ordering, no stale descriptor. + peer_x = {"peer-id": 0x101, "type": "accel", "port-index": 0} + peer_y = {"peer-id": 0x202, "type": "accel", "port-index": 0} + fab.do("port-peer-new", {"endpoint-id": EP, "port-index": UP, + "peer": peer_x}) + px = _port(fab, EP, UP).get("peer") + fab.do("port-peer-del", {"endpoint-id": EP, "port-index": UP}) + pmid = _port(fab, EP, UP).get("peer") + fab.do("port-peer-new", {"endpoint-id": EP, "port-index": UP, + "peer": peer_y}) + py = _port(fab, EP, UP).get("peer") + ksft.check(px is not None and px.get("peer-id") == 0x101, + "linkfail-peer-install-x", "peer=%s" % px) + ksft.check(pmid is None, "linkfail-peer-del-clears", "peer=%s" % pmid) + ksft.check(py is not None and py.get("peer-id") == 0x202, + "linkfail-peer-replace-y-no-stale", "peer=%s" % py) + + # Final query matches the reported stream: oper active + peer Y. + pf0 = _port(fab, EP, PP) + pfu = _port(fab, EP, UP) + ksft.check(pf0.get("oper-state") == "active" and + (pfu.get("peer") or {}).get("peer-id") == 0x202, + "linkfail-final-query-matches", + "oper=%s peer=%s" % (pf0.get("oper-state"), + pfu.get("peer"))) + finally: + try: + fab.do("port-peer-del", {"endpoint-id": EP, "port-index": UP}) + except NlError: + pass + L.dbg_write("ep%d/port%d/inject" % (EP, PP), "recover_to_active") + + +CASES = ( + test_orchestrated_startup, + test_link_failure_and_recovery, +) + + +def main(): + ksft = L.Ksft() + _, NlError = L.import_ynl() + + with L.fabricsim(ksft, need_debugfs=True, need_control="add_orphan") as fab: + fid = fabricsim_fid(fab) + if fid is None: + ksft.skip_all("fabricsim fabric not present") + if not L.family_has_op(fab, "fabric-new"): + ksft.skip_all("mutation ABI absent (query-only build)") + + L.run_cases(ksft, Cfg(fab, fid, NlError), CASES) + ksft.finish() + + +if __name__ == "__main__": + main() diff --git a/tools/testing/selftests/drivers/gpu/drm_fabric/switch_abi.py b/tools/testing/selftests/drivers/gpu/drm_fabric/switch_abi.py index 775be4ac2160..152d16cdc464 100755 --- a/tools/testing/selftests/drivers/gpu/drm_fabric/switch_abi.py +++ b/tools/testing/selftests/drivers/gpu/drm_fabric/switch_abi.py @@ -4,7 +4,9 @@ """ fabricsim's "switch" shape links each leaf's first port to an opaque switch that is not a registered endpoint: asserts half-edge serialization -and peer-id non-resolution, not leaf-switch-leaf reachability. +and peer-id non-resolution, not leaf-switch-leaf reachability. A final case +reloads at a one-port-per-endpoint request, where the port reserved for +userspace peers would otherwise consume the only half-edge. --no-load is ignored (needs a fresh insmod). Run as root. """ @@ -78,11 +80,38 @@ def test_switch_id_does_not_resolve(ksft, cfg): % sorted(leaked)) +def test_minimum_request_preserves_switch_wiring(ksft, cfg): + """A one-port-per-endpoint request must still leave the switch wired: + setup raises the count so the reserved userspace port does not consume + the only half-edge. + + Runs last: it reloads the sim, invalidating the snapshot above. + """ + fab = cfg.fab + L.rmmod("drm_fabric_sim") + if not L.insmod("drm-fabric-sim.ko", "topology=switch", "ports_per_ep=1"): + ksft.skip("switch-minimum-request-wired", + "could not load sim with ports_per_ep=1") + return + if not L.wait_until(lambda: L.module_loaded("drm_fabric_sim")): + ksft.skip("switch-minimum-request-wired", "sim did not reappear") + return + + eps = [e["endpoint"] for e in fab.dump("endpoint-get", {})] + sim_eps = [e for e in eps if e["name"].startswith("sim-ep")] + peers = switch_peers(fab, sim_eps) + ksft.check(bool(sim_eps) and len(peers) == len(sim_eps), + "switch-minimum-request-wired", + "ports_per_ep=1: switch-peers=%d leaves=%d" + % (len(peers), len(sim_eps))) + + CASES = ( test_every_leaf_has_switch_peer, test_half_edge_fully_serialized, test_single_opaque_switch_id, test_switch_id_does_not_resolve, + test_minimum_request_preserves_switch_wiring, ) -- 2.43.0