Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,8 @@ jobs:
for rpm in rpms/plugin-hostd-tests-[0-9]*.${{ matrix.arch }}.rpm; do
rpm -qlp "$rpm"
rpm -qp --qf '[%{FILEMODES:perms} %{FILENAMES}\n]' "$rpm" | grep -v -e '^d' -e '/\.build-id/' | cut -d' ' -f2 | sort > files.txt
printf '%s\n' /usr/libexec/plugin-hostd/tests/fake-host /usr/share/licenses/plugin-hostd-tests/COPYING | sort > expected.txt
printf '%s\n' /usr/libexec/plugin-hostd/tests/fake-host /usr/libexec/plugin-hostd/tests/lib/protocol.sh /usr/libexec/plugin-hostd/tests/lib/plugin_hostd_client.py \
/usr/share/licenses/plugin-hostd-tests/COPYING | sort > expected.txt
diff -u expected.txt files.txt
# tied to the same build: the requirement names the exact version-release and the isa of this very package
case "${{ matrix.arch }}" in x86_64) isa=x86-64 ;; aarch64) isa=aarch-64 ;; *) echo "no isa for ${{ matrix.arch }}"; exit 1 ;; esac
Expand Down Expand Up @@ -296,6 +297,9 @@ jobs:
set -euo pipefail
chroot /tmp/clean-root /usr/libexec/plugin-hostd/tests/fake-host -L | tee layout.txt
test -s layout.txt
# and the protocol clients it ships load
chroot /tmp/clean-root bash -c '. /usr/libexec/plugin-hostd/tests/lib/protocol.sh && type protocol_send protocol_expect protocol_wait_ready send'
chroot /tmp/clean-root python3 -c 'import sys; sys.path.insert(0, "/usr/libexec/plugin-hostd/tests/lib"); import plugin_hostd_client'
chroot /tmp/clean-root env PLUGIN_HOSTD=/usr/bin/plugin-hostd PROBE=/usr/bin/plugin-hostd-probe FAKE_HOST=/usr/libexec/plugin-hostd/tests/fake-host \
PLUGIN_HOSTD_PROTOCOL=/usr/share/plugin-hostd/protocol.json python3 /smoke/tests/daemon_test.py

Expand Down Expand Up @@ -415,6 +419,8 @@ jobs:
dpkg -L plugin-hostd | grep -qx /usr/share/plugin-hostd/mod-host.json
dpkg -L plugin-hostd-tests | tee tests.txt
grep -qx /usr/libexec/plugin-hostd/tests/fake-host tests.txt
grep -qx /usr/libexec/plugin-hostd/tests/lib/protocol.sh tests.txt
grep -qx /usr/libexec/plugin-hostd/tests/lib/plugin_hostd_client.py tests.txt
dpkg -s libmod-host-protocol0 | grep '^Status: install ok installed'
dpkg-deb -f debs/plugin-hostd_*.deb Depends | tee depends.txt
grep -qF 'libmod-host-protocol0 (>= 0.1.0-6~fmx)' depends.txt
Expand Down
11 changes: 9 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -160,10 +160,12 @@ test-format-coverage:
python3 tests/format_coverage.py --contract-dir $(CONTRACTDIR) --major $(CONTRACT_SCHEMA_MAJOR) lv2
python3 tests/format_coverage.py --contract-dir $(CONTRACTDIR) --major $(CONTRACT_SCHEMA_MAJOR) --self-test

# the fake worker, for a program that drives plugin-hostd: away from $(BINDIR), where nothing runs it by chance
# the fake worker and the protocol clients (lib/protocol.sh, lib/plugin_hostd_client.py), for a program that drives plugin-hostd: away from $(BINDIR), where nothing runs it by chance
install_tests: $(FAKE)
install -d $(DESTDIR)$(LIBEXECDIR)/plugin-hostd/tests
install -m 755 $(FAKE) $(DESTDIR)$(LIBEXECDIR)/plugin-hostd/tests/fake-host
install -d $(DESTDIR)$(LIBEXECDIR)/plugin-hostd/tests/lib
install -m 644 tests/lib/protocol.sh tests/lib/plugin_hostd_client.py $(DESTDIR)$(LIBEXECDIR)/plugin-hostd/tests/lib

install_man:
install -d $(DESTDIR)$(MANDIR)
Expand Down Expand Up @@ -254,9 +256,14 @@ clean:

# the daemon against workers that are not plugin hosts at all (tests/fake-host speaks the same protocol and can be
# made to die on cue): placement, forwarding, ledger, replay, attribution, the storm bound; no jack, no plugin
test: test-pin test-transforms test-format-coverage test-describe test-relay test-alloc test-format test-daemon test-verbs-declared check-rendered check-generated check-schema test-probe-schema test-vocabulary test-probe test-contract test-consumer test-perturbation check-readme check-own-host-page check-no-pins check-ci-guards test-mod-host-json test-socket check-verb-table check-preset-schema test-install-schemas
test: test-protocol-lib test-pin test-transforms test-format-coverage test-describe test-relay test-alloc test-format test-daemon test-verbs-declared check-rendered check-generated check-schema test-probe-schema test-vocabulary test-probe test-contract test-consumer test-perturbation check-readme check-own-host-page check-no-pins check-ci-guards test-mod-host-json test-socket check-verb-table check-preset-schema test-install-schemas
MOD_HOST_DIR=$(MOD_HOST_DIR) python3 tests/verbs_contract.py

# the protocol clients plugin-hostd-tests ships (tests/lib/), against a peer that frames by NUL, and their sabotage
.PHONY: test-protocol-lib
test-protocol-lib:
python3 tests/protocol_lib.py

# the same without the verb table, which is read from a mod-host checkout's README
test-daemon: $(PROG) $(FAKE)
PLUGIN_HOSTD=./$(PROG) FAKE_HOST=./$(FAKE) python3 tests/daemon_test.py
Expand Down
2 changes: 2 additions & 0 deletions debian/plugin-hostd-tests.install
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
usr/libexec/plugin-hostd/tests/fake-host
usr/libexec/plugin-hostd/tests/lib/protocol.sh
usr/libexec/plugin-hostd/tests/lib/plugin_hostd_client.py
5 changes: 5 additions & 0 deletions debian/rules
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,8 @@ override_dh_auto_test:
ifeq (,$(filter nocheck,$(DEB_BUILD_OPTIONS)))
$(MAKE) test-daemon check-generated check-rendered
endif

# the protocol clients are sourced and imported, never run: no execute bit
override_dh_fixperms:
dh_fixperms
chmod 0644 debian/plugin-hostd-tests/usr/libexec/plugin-hostd/tests/lib/protocol.sh debian/plugin-hostd-tests/usr/libexec/plugin-hostd/tests/lib/plugin_hostd_client.py
2 changes: 2 additions & 0 deletions packaging/plugin-hostd.spec
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ make test-daemon check-generated check-rendered
%files tests
%license COPYING
%{_libexecdir}/plugin-hostd/tests/fake-host
%{_libexecdir}/plugin-hostd/tests/lib/protocol.sh
%{_libexecdir}/plugin-hostd/tests/lib/plugin_hostd_client.py

%files devel
%license COPYING
Expand Down
18 changes: 9 additions & 9 deletions tests/harness.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# The daemon under test and a controller for it: a command socket, a feedback socket read on a thread, and
# helpers that wait for what the daemon does instead of sleeping and hoping.
import json, os, re, shutil, signal, socket, subprocess, tempfile, threading, time
import json, os, re, shutil, signal, socket, subprocess, sys, tempfile, threading, time

sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "lib"))
import plugin_hostd_client

# The protocol the daemon declares, read from the file a consumer reads: the tree's own, or PLUGIN_HOSTD_PROTOCOL (the
# installed package's /usr/share/plugin-hostd/protocol.json, in the smoke test of a package). No value of it is
Expand Down Expand Up @@ -116,6 +119,7 @@ def __init__(self, exe, worker, clap_worker=None, conf=None, env=None, feedback=
self.events = []
self.lock = threading.Lock()
self.sock = socket.create_connection(("127.0.0.1", self.cmd_port), timeout=10)
self.client = plugin_hostd_client.Client(self.sock)
self.fb = socket.create_connection(("127.0.0.1", self.fb_port), timeout=10) if feedback else None
if self.fb:
self.fb.settimeout(None) # silence on the feedback port is not an error
Expand All @@ -142,14 +146,10 @@ def _read_feedback(self):
self.events.append(line.decode())

def send(self, msg):
self.sock.sendall(msg.encode() + b"\0")
buf = b""
while not buf.endswith(b"\0"):
c = self.sock.recv(65536)
if not c:
raise Fail("daemon closed on " + msg)
buf += c
return buf[:-1].decode()
try:
return self.client.send(msg)
except ConnectionError:
raise Fail("daemon closed on " + msg)

def ok(self, msg, expect=None):
r = self.send(msg)
Expand Down
57 changes: 57 additions & 0 deletions tests/lib/plugin_hostd_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""The mod-host line protocol from Python, the one client every test of plugin-hostd, omx-clap-host and the controllers
imports: a command is one line ended by a NUL, a reply is one NUL-ended line. Installed by plugin-hostd-tests as
/usr/libexec/plugin-hostd/tests/lib/plugin_hostd_client.py, and in this tree as tests/lib/plugin_hostd_client.py.

c = Client(sock) a connected socket, or Client.connect(port)
c.send(line) the reply; ConnectionError when the peer closes, socket.timeout when none comes in time
c.try_send(line, timeout) (reply, None), or (None, 'closed' | 'timeout' | 'oserror <why>'): a test that expects a death
c.expect(line, wanted) the reply, AssertionError when it is not wanted
"""
import socket
import time


class Client:
def __init__(self, sock, timeout=10.0):
self.sock = sock
self.timeout = timeout
self.buf = b""

@classmethod
def connect(cls, port, host="127.0.0.1", timeout=10.0):
return cls(socket.create_connection((host, port), timeout=timeout), timeout)

def send(self, line, timeout=None):
self.sock.sendall(line.encode() + b"\0")
end = time.monotonic() + (self.timeout if timeout is None else timeout)
while b"\0" not in self.buf:
left = end - time.monotonic()
if left <= 0:
raise socket.timeout("no reply to %r" % line)
self.sock.settimeout(left)
chunk = self.sock.recv(65536)
if not chunk:
raise ConnectionError("the peer closed on " + line)
self.buf += chunk
reply, self.buf = self.buf.split(b"\0", 1)
return reply.decode(errors="replace")

def try_send(self, line, timeout=None):
try:
return self.send(line, timeout), None
except socket.timeout:
return None, "timeout"
except ConnectionError:
return None, "closed"
except OSError as e:
return None, "oserror %s" % e

def expect(self, line, wanted):
reply = self.send(line)
if reply != wanted:
raise AssertionError("%s -> %r, wanted %r" % (line, reply, wanted))
return reply

def close(self):
self.sock.close()
43 changes: 43 additions & 0 deletions tests/lib/protocol.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# The mod-host line protocol from a shell, the one client every test of plugin-hostd, omx-clap-host and the
# controllers sources: a command is one line ended by a NUL, a reply is one NUL-ended line. Installed by
# plugin-hostd-tests as /usr/libexec/plugin-hostd/tests/lib/protocol.sh, and in this tree as tests/lib/protocol.sh.
#
# protocol_send FD SECONDS LINE the reply to LINE on the open descriptor FD, on stdout
# protocol_expect FD SECONDS LINE WANTED 0 when the reply is WANTED, 1 and a line on stderr when it is not
# protocol_wait_ready LOG [TRIES] 0 when LOG holds the readiness marker within TRIES tenths of a second (50)
# send LINE protocol_send on PROTOCOL_FD (3) for PROTOCOL_TIMEOUT (5) seconds
#
# A script that talks to one socket opens it on a descriptor (exec 3<>/dev/tcp/127.0.0.1/$port), sets PROTOCOL_FD and
# PROTOCOL_TIMEOUT if it differs from the defaults, and calls send; one that talks to several calls protocol_send.

# a worker prints a line ending with this once its socket accepts (protocol.h PHD_WORKER_READY_MARKER)
PROTOCOL_READY_MARKER=${PROTOCOL_READY_MARKER:-ready!}

protocol_send() {
local reply=
printf '%s\0' "$3" >&"$1"
IFS= read -r -d '' -t "$2" reply <&"$1"
printf '%s\n' "$reply"
}

protocol_expect() {
local reply
reply=$(protocol_send "$1" "$2" "$3")
[ "$reply" = "$4" ] && return 0
printf 'protocol_expect: %s -> %s, wanted %s\n' "$3" "$reply" "$4" >&2
return 1
}

protocol_wait_ready() {
local i
for ((i = 0; i < ${2:-50}; i++)); do
grep -q -F "$PROTOCOL_READY_MARKER" "$1" 2>/dev/null && return 0
sleep 0.1
done
grep -q -F "$PROTOCOL_READY_MARKER" "$1" 2>/dev/null
}

send() {
protocol_send "${PROTOCOL_FD:-3}" "${PROTOCOL_TIMEOUT:-5}" "$1"
}
135 changes: 135 additions & 0 deletions tests/protocol_lib.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
# The two protocol clients plugin-hostd-tests ships (tests/lib/protocol.sh, tests/lib/plugin_hostd_client.py) against a
# peer that answers in NUL-ended replies, split across writes, then its sabotage: a copy of each client that frames by
# newline instead of NUL must fail the same checks, so the checks can tell a client that frames wrongly.
import os, re, socket, subprocess, sys, tempfile, threading, time

HERE = os.path.dirname(os.path.abspath(__file__))
LIB = os.path.join(HERE, "lib")


def serve(srv, stop):
"""echo "resp <line>" as one reply, written in two pieces; "mute" is never answered; "bye" closes"""
while not stop.is_set():
try:
c, _ = srv.accept()
except OSError:
return
buf = b""
while True:
try:
d = c.recv(4096)
except OSError:
break
if not d:
break
buf += d
while b"\0" in buf:
line, buf = buf.split(b"\0", 1)
if line == b"mute":
continue
if line == b"bye":
c.close()
break
reply = b"resp " + line + b"\0"
c.sendall(reply[:3])
time.sleep(0.02)
c.sendall(reply[3:])
c.close()


def shell(lib, port, script):
"""a bash that opens the peer on descriptor 3, sources `lib` and runs `script`; its stdout, stderr and status"""
body = 'exec 3<>/dev/tcp/127.0.0.1/%d\n. %s\n%s' % (port, lib, script)
p = subprocess.run(["bash", "-c", body], capture_output=True, text=True, timeout=30)
return p.stdout, p.stderr, p.returncode


def check_shell(lib, port, failures):
out, _, rc = shell(lib, port, 'PROTOCOL_TIMEOUT=2; send "add x 0"; protocol_send 3 2 "a b"')
if out != "resp add x 0\nresp a b\n":
failures.append("shell send: %r" % out)
_, _, rc = shell(lib, port, 'protocol_expect 3 2 "ping" "resp ping"')
if rc != 0:
failures.append("shell expect of the right reply returned %d" % rc)
_, err, rc = shell(lib, port, 'protocol_expect 3 2 "ping" "resp pong"')
if rc == 0 or "resp ping" not in err:
failures.append("shell expect of the wrong reply returned %d, said %r" % (rc, err))
with tempfile.NamedTemporaryFile("w", suffix=".log") as log:
_, _, rc = shell(lib, port, 'protocol_wait_ready %s 2' % log.name)
if rc == 0:
failures.append("a log without the marker was ready")
log.write("starting\nplugin-hostd ready!\n")
log.flush()
_, _, rc = shell(lib, port, 'protocol_wait_ready %s 2' % log.name)
if rc != 0:
failures.append("a log with the marker was not ready")


def check_python(libdir, port, failures):
sys.path.insert(0, libdir)
sys.modules.pop("plugin_hostd_client", None)
import plugin_hostd_client as m
c = m.Client.connect(port)
if c.send("add x 0") != "resp add x 0":
failures.append("python send")
if c.expect("ping", "resp ping") != "resp ping":
failures.append("python expect")
try:
c.expect("ping", "resp pong")
failures.append("python expect of the wrong reply raised nothing")
except AssertionError:
pass
if c.try_send("mute", 0.2) != (None, "timeout"):
failures.append("python timeout")
c2 = m.Client.connect(port)
if c2.try_send("bye", 1) != (None, "closed"):
failures.append("python closed")
sys.path.pop(0)


def run(libsh, libpy, port, failures):
check_shell(libsh, port, failures)
check_python(libpy, port, failures)


def main():
srv = socket.socket()
srv.bind(("127.0.0.1", 0))
srv.listen(16)
port = srv.getsockname()[1]
stop = threading.Event()
for _ in range(8):
threading.Thread(target=serve, args=(srv, stop), daemon=True).start()
good = []
run(os.path.join(LIB, "protocol.sh"), LIB, port, good)
if good:
print("FAIL the clients:", *good, sep="\n ")
return 1
print("ok: the shell and the python client frame by NUL, split replies, time out and close")
# sabotage: frame by newline, and the same checks must fail
with tempfile.TemporaryDirectory() as d:
sh = open(os.path.join(LIB, "protocol.sh")).read().replace("'%s\\0'", "'%s\\n'").replace("-d ''", "-d $'\\n'")
py = open(os.path.join(LIB, "plugin_hostd_client.py")).read().replace('b"\\0"', 'b"\\n"')
if sh == open(os.path.join(LIB, "protocol.sh")).read() or py == open(os.path.join(LIB, "plugin_hostd_client.py")).read():
print("FAIL the sabotage changed nothing")
return 1
open(os.path.join(d, "protocol.sh"), "w").write(sh)
open(os.path.join(d, "plugin_hostd_client.py"), "w").write(py)
bad_sh, bad_py = [], []
check_shell(os.path.join(d, "protocol.sh"), port, bad_sh)
try:
check_python(d, port, bad_py)
except Exception as e:
bad_py.append(repr(e))
if not bad_sh or not bad_py:
print("FAIL a client that frames by newline passed: shell %r, python %r" % (bad_sh, bad_py))
return 1
print("ok: a client that frames by newline fails the same checks")
stop.set()
return 0


if __name__ == "__main__":
sys.exit(main())
15 changes: 7 additions & 8 deletions tests/verbs_declared.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
import json, os, re, socket, subprocess, sys, tempfile, time

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "lib"))
import harness
import plugin_hostd_client
import scan_fixture
from harness import Daemon, Fail, check, wait_for

Expand Down Expand Up @@ -327,6 +329,7 @@ def __init__(self):
while True:
try:
self.sock = socket.create_connection(("127.0.0.1", self.port), timeout=10)
self.client = plugin_hostd_client.Client(self.sock)
self.fb = socket.create_connection(("127.0.0.1", fb), timeout=10)
break
except OSError:
Expand All @@ -335,14 +338,10 @@ def __init__(self):
time.sleep(0.02)

def send(self, msg):
self.sock.sendall(msg.encode() + b"\0")
buf = b""
while not buf.endswith(b"\0"):
c = self.sock.recv(65536)
if not c:
raise Fail("the fake worker closed on " + msg)
buf += c
return buf[:-1].decode()
try:
return self.client.send(msg)
except ConnectionError:
raise Fail("the fake worker closed on " + msg)

def close(self):
self.proc.kill()
Expand Down
Loading