mirror of
https://github.com/openhop-dev/openhop_repeater.git
synced 2026-10-23 21:43:02 +00:00
294 lines
9.9 KiB
Bash
Executable File
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")
|
|
'
|