Files

294 lines
9.9 KiB
Bash
Executable File

#!/bin/sh
set -eu
IMAGE="${1:-openhop-repeater:plugin-smoke}"
SUFFIX="${GITHUB_RUN_ID:-$$}-${GITHUB_RUN_ATTEMPT:-0}"
CONTAINER="openhop-plugin-smoke-${SUFFIX}"
CONFIG_VOLUME="openhop-plugin-smoke-config-${SUFFIX}"
DATA_VOLUME="openhop-plugin-smoke-data-${SUFFIX}"
cleanup() {
docker rm -f "${CONTAINER}" >/dev/null 2>&1 || true
docker volume rm "${CONFIG_VOLUME}" "${DATA_VOLUME}" >/dev/null 2>&1 || true
}
trap cleanup EXIT HUP INT TERM
start_container() {
docker run -d --network none \
--name "${CONTAINER}" \
-v "${CONFIG_VOLUME}:/etc/openhop_repeater" \
-v "${DATA_VOLUME}:/var/lib/openhop_repeater" \
"${IMAGE}" >/dev/null
}
wait_for_manager() {
attempts=0
while [ "${attempts}" -lt 60 ]; do
if docker exec "${CONTAINER}" python3 -c \
'from repeater.plugins.ipc import PluginIPCClient; PluginIPCClient("/var/lib/openhop_repeater/plugin-manager.sock").call("ping")' \
>/dev/null 2>&1; then
return 0
fi
attempts=$((attempts + 1))
sleep 1
done
docker logs "${CONTAINER}" >&2 || true
echo "Plugin manager did not become ready" >&2
return 1
}
wait_for_http() {
attempts=0
while [ "${attempts}" -lt 60 ]; do
if docker exec "${CONTAINER}" python3 -c \
'import urllib.request; assert urllib.request.urlopen("http://127.0.0.1:8000/", timeout=2).status == 200' \
>/dev/null 2>&1; then
return 0
fi
attempts=$((attempts + 1))
sleep 1
done
docker logs "${CONTAINER}" >&2 || true
echo "Repeater HTTP server did not become ready" >&2
return 1
}
docker volume create "${CONFIG_VOLUME}" >/dev/null
docker volume create "${DATA_VOLUME}" >/dev/null
start_container
wait_for_manager
wait_for_http
echo "Checking local wheel upload"
docker exec -i "${CONTAINER}" python3 - <<'PY'
import json
import urllib.request
import uuid
import zipfile
from pathlib import Path
wheel_path = Path("/tmp/openhop_smoke_plugin-0.1.0-py3-none-any.whl")
manifest = {
"schema": 1,
"id": "openhop.smoke",
"name": "Container Smoke Plugin",
"version": "0.1.0",
"runtime": {"type": "python", "entrypoint": "openhop-smoke-plugin"},
}
module = '''import os
import signal
import time
from pathlib import Path
running = True
lifecycle = Path(os.environ["OPENHOP_PLUGIN_DATA"]) / "lifecycle.log"
def stop(signum, frame):
global running
with lifecycle.open("a", encoding="utf-8") as handle:
handle.write(f"TERM {os.getpid()}\\n")
running = False
def main():
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
child = os.fork()
with lifecycle.open("a", encoding="utf-8") as handle:
kind = "START" if child else "CHILD"
handle.write(f"{kind} {os.getpid()}\\n")
while running:
time.sleep(0.1)
if child:
os.waitpid(child, 0)
'''
metadata = "Metadata-Version: 2.1\nName: openhop-smoke-plugin\nVersion: 0.1.0\n"
wheel = "Wheel-Version: 1.0\nGenerator: openhop-container-smoke\nRoot-Is-Purelib: true\nTag: py3-none-any\n"
entries = "[console_scripts]\nopenhop-smoke-plugin = openhop_smoke_plugin:main\n"
record = "\n".join(
[
"openhop_smoke_plugin.py,,",
"openhop_smoke_plugin-0.1.0.dist-info/METADATA,,",
"openhop_smoke_plugin-0.1.0.dist-info/WHEEL,,",
"openhop_smoke_plugin-0.1.0.dist-info/entry_points.txt,,",
"openhop_smoke_plugin-0.1.0.dist-info/RECORD,,",
"share/openhop/plugins/openhop.smoke/openhop-plugin.json,,",
]
) + "\n"
with zipfile.ZipFile(wheel_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
archive.writestr("openhop_smoke_plugin.py", module)
archive.writestr("openhop_smoke_plugin-0.1.0.dist-info/METADATA", metadata)
archive.writestr("openhop_smoke_plugin-0.1.0.dist-info/WHEEL", wheel)
archive.writestr("openhop_smoke_plugin-0.1.0.dist-info/entry_points.txt", entries)
archive.writestr("openhop_smoke_plugin-0.1.0.dist-info/RECORD", record)
archive.writestr(
"share/openhop/plugins/openhop.smoke/openhop-plugin.json",
json.dumps(manifest),
)
def request(path, *, body=None, headers=None, method=None):
req = urllib.request.Request(
"http://127.0.0.1:8000" + path,
data=body,
headers=headers or {},
method=method,
)
with urllib.request.urlopen(req, timeout=120) as response:
assert response.status == 200
return json.loads(response.read())
login = request(
"/auth/login",
body=json.dumps(
{"username": "admin", "password": "admin123", "client_id": "docker-smoke"}
).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
assert login["success"] is True
headers = {"Authorization": "Bearer " + login["token"]}
boundary = "----openhop-smoke-" + uuid.uuid4().hex
upload = (
f'--{boundary}\r\nContent-Disposition: form-data; name="wheel"; filename="{wheel_path.name}"\r\n'
"Content-Type: application/octet-stream\r\n\r\n"
).encode() + wheel_path.read_bytes() + f"\r\n--{boundary}--\r\n".encode()
installed = request(
"/api/plugins/install",
body=upload,
headers={**headers, "Content-Type": "multipart/form-data; boundary=" + boundary},
method="POST",
)
assert installed["success"] is True
assert installed["plugin"]["id"] == "openhop.smoke"
release_dir = Path(
"/var/lib/openhop_repeater/plugins/openhop.smoke/releases/0.1.0"
)
assert any(release_dir.glob("*.whl")), "installed wheel was not archived"
enabled = request(
"/api/plugins/enable",
body=json.dumps({"id": "openhop.smoke"}).encode(),
headers={**headers, "Content-Type": "application/json"},
method="POST",
)
assert enabled["success"] is True
assert enabled["plugin"]["state"] == "RUNNING"
PY
echo "Checking plugin-manager crash recovery"
docker exec "${CONTAINER}" python3 -c '
import os
import signal
from pathlib import Path
needle = b"\x00-m\x00repeater.plugins\x00"
for cmdline in Path("/proc").glob("[0-9]*/cmdline"):
try:
data = cmdline.read_bytes()
except OSError:
continue
if needle in data:
os.kill(int(cmdline.parent.name), signal.SIGKILL)
break
else:
raise SystemExit("plugin manager process not found")
'
wait_for_manager
attempts=0
while [ "${attempts}" -lt 30 ]; do
starts="$(docker exec "${CONTAINER}" python3 -c '
from pathlib import Path
path = Path("/var/lib/openhop_repeater/plugins/openhop.smoke/data/lifecycle.log")
print(sum(line.startswith("START ") for line in path.read_text().splitlines()))
' 2>/dev/null || printf '0')"
if [ "${starts}" -ge 2 ]; then
break
fi
attempts=$((attempts + 1))
sleep 1
done
[ "${starts}" -ge 2 ]
docker exec "${CONTAINER}" python3 -c '
from pathlib import Path
zombies = []
for stat_path in Path("/proc").glob("[0-9]*/stat"):
try:
fields = stat_path.read_text().split()
except OSError:
continue
if len(fields) > 2 and fields[2] == "Z":
zombies.append(stat_path.parent.name)
assert not zombies, f"zombie processes remain after manager restart: {zombies}"
'
echo "Checking container recreation persistence"
docker stop -t 10 "${CONTAINER}" >/dev/null
docker rm "${CONTAINER}" >/dev/null
start_container
wait_for_manager
wait_for_http
docker exec "${CONTAINER}" python3 -c '
from repeater.plugins.ipc import PluginIPCClient
client = PluginIPCClient("/var/lib/openhop_repeater/plugin-manager.sock")
status = client.status("openhop.smoke")
assert status["enabled"] is True
assert status["state"] == "RUNNING"
'
echo "Checking plugin stop/start, descendants, permissions and venv rebuild"
docker exec -i "${CONTAINER}" python3 - <<'PY'
import os
import re
import stat
import sys
import time
from pathlib import Path
from repeater.plugins.ipc import PluginIPCClient
client = PluginIPCClient("/var/lib/openhop_repeater/plugin-manager.sock")
root = Path("/var/lib/openhop_repeater/plugins/openhop.smoke")
log = root / "data/lifecycle.log"
for _ in range(50):
lines = log.read_text().splitlines()
children = [int(line.split()[1]) for line in lines if line.startswith("CHILD ")]
if children:
break
time.sleep(0.1)
assert children
pid = client.status("openhop.smoke")["pid"]
child = children[-1]
assert Path(f"/proc/{child}").exists()
client.call("stop", plugin_id="openhop.smoke")
for _ in range(50):
if not any(Path(f"/proc/{p}").exists() for p in (pid, child)):
break
time.sleep(0.1)
assert not any(Path(f"/proc/{p}").exists() for p in (pid, child))
assert f"TERM {pid}" in log.read_text().splitlines()
assert f"TERM {child}" in log.read_text().splitlines()
assert os.getuid() == 15888
assert root.stat().st_uid == 15888
assert stat.S_IMODE(Path(client.socket_path).stat().st_mode) == 0o660
venv_config = root / "releases/0.1.0/venv/pyvenv.cfg"
venv_config.write_text(re.sub(r"(?m)^version = .*", "version = 3.11.0", venv_config.read_text()))
client.call("start", plugin_id="openhop.smoke")
assert client.status("openhop.smoke")["state"] == "RUNNING"
assert f"version = {sys.version_info.major}.{sys.version_info.minor}." in venv_config.read_text()
print("stop/start descendants, non-root/socket permissions, real offline venv rebuild passed")
PY
echo "Checking graceful plugin shutdown"
EXPECTED_PLUGIN_PID="$(docker exec "${CONTAINER}" python3 -c '
from repeater.plugins.ipc import PluginIPCClient
client = PluginIPCClient("/var/lib/openhop_repeater/plugin-manager.sock")
print(client.status("openhop.smoke")["pid"])
')"
docker stop -t 10 "${CONTAINER}" >/dev/null
docker run --rm --network none --entrypoint python3 -e EXPECTED_PLUGIN_PID="${EXPECTED_PLUGIN_PID}" \
-v "${DATA_VOLUME}:/var/lib/openhop_repeater" \
"${IMAGE}" -c '
import os
from pathlib import Path
lines = Path("/var/lib/openhop_repeater/plugins/openhop.smoke/data/lifecycle.log").read_text().splitlines()
assert sum(line.startswith("START ") for line in lines) >= 3, lines
expected = "TERM " + os.environ["EXPECTED_PLUGIN_PID"]
assert expected in lines, (expected, lines)
print("container plugin smoke test passed")
'