Add a health check for the daemon and RPC endpoint (#34)

* Add a health check for the daemon and RPC endpoint

SeafileClient.is_healthy() checks seaf-cli status and makes a real RPC
call (get_repo_list) against the daemon's Unix socket - neither needs
the Seafile server, so it works without a token or password.

healthcheck.py exposes it as a plain script for Docker's HEALTHCHECK.
Each check is a fresh process spawned by the Docker daemon as root,
unlike the long-lived start.py process, so it drops to the seafile
user with runuser before touching the RPC socket - the one place that
mechanism still belongs after the earlier in-process privilege drop.

Verified against the real image: healthcheck.py exits 0 once the real
daemon is up (through the actual main() entrypoint, not a test
fixture) and nonzero when no daemon has been started.

* Assert the return value in the daemon-down health check test

Copilot review: the test checked the RPC call was skipped but not
what is_healthy() actually returned in that case.

---------

Co-authored-by: Maxim Shulga <maxim.shulga@alludo.com>
This commit is contained in:
Maksim ShulgaandMaxim Shulga authored and GitHub committed 2026-08-03 18:38:19 +00:00
1 parent ba5ad177b7
commit 28db575c92
7 files changed
+133 -4

No files matched your search

+3 -1
View File
@@ -1,7 +1,9 @@
# Only requirements.txt, dsc/ and start.py belong in the build context.
# Only requirements.txt, dsc/, start.py, and healthcheck.py belong in the
# build context.
*
!requirements.txt
!start.py
!healthcheck.py
!dsc/
# Keep local artifacts out even though dsc/ is re-included above.
+11 -2
View File
@@ -61,9 +61,10 @@ RUN pip install --no-cache-dir --upgrade "setuptools>=78.1.1" && \
# Copy app
COPY dsc ./dsc/
COPY start.py ./start.py
COPY healthcheck.py ./healthcheck.py
# Create seafile user and init seafile client
RUN chmod +x /dsc/start.py && \
RUN chmod +x /dsc/start.py /dsc/healthcheck.py && \
useradd -U -d /dsc -s /bin/bash seafile && \
usermod -G users seafile && \
mkdir -p /dsc/seafile-data && \
@@ -74,11 +75,19 @@ RUN chmod +x /dsc/start.py && \
RUN seaf-cli --help | grep -q 'download-by-name' && \
seaf-cli sync --help | grep -q '\--token' && \
seaf-cli list --help | grep -q '\--json' && \
python3 -c "import seafile, pysearpc, dsc.client, dsc.misc"
python3 -c "import seafile, pysearpc, dsc.client, dsc.misc, healthcheck"
# Record the pinned client version in the image so it can be checked at
# runtime; seaf-cli itself has no --version flag.
ENV SEAFILE_CLI_VERSION=${SEAFILE_CLI_VERSION}
VOLUME /dsc/seafile-data
# The container's own process already runs as the seafile user (see
# dsc.misc.drop_privileges), but a HEALTHCHECK is a fresh process started by
# the Docker daemon, which starts it as root. runuser drops it to the same
# unprivileged user before it ever touches the RPC socket.
HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \
CMD runuser -u seafile -- python3 /dsc/healthcheck.py
CMD ["./start.py"]
+6 -1
View File
@@ -208,7 +208,12 @@ Acceptance criteria:
- [x] Reject UID or GID zero unless a documented use case requires it.
`main()` now exits with code 2 and a clear message if `--uid`/`SEAFILE_UID`
or `--gid`/`SEAFILE_GID` resolve to 0, before `setup_uid()` ever runs.
- [ ] Add a health check for the daemon and RPC endpoint.
- [x] Add a health check for the daemon and RPC endpoint.
`SeafileClient.is_healthy()` checks `seaf-cli status` and a real RPC call
(`get_repo_list`); `healthcheck.py` exposes it to Docker's `HEALTHCHECK`.
Each check is a fresh root-spawned process (unlike the long-lived
`start.py`), so it drops to the `seafile` user with `runuser` before
touching the RPC socket - the one place that mechanism still belongs.
- [ ] Handle temporary RPC failures and daemon restarts without corrupting
state.
- [ ] Document supported CPU architectures instead of implying multi-platform
+16
View File
@@ -165,6 +165,22 @@ class SeafileClient:
self.__wait_for_daemon(False, const.DAEMON_STOP_TIMEOUT, "stopping")
_lg.info("Seafile daemon is stopped")
def is_healthy(self) -> bool:
"""
Report whether the daemon process and its RPC socket both respond.
Used by the container's HEALTHCHECK. Neither check needs the Seafile
server, so this works without a token or password.
"""
if not self.daemon_ready:
return False
try:
self.rpc.get_repo_list(-1, -1)
except Exception as err:
_lg.warning("RPC endpoint is not responding: %s", err)
return False
return True
def get_library_id(self, library) -> Optional[str]:
"""
Resolve a requested name or ID to the one library ID it names.
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env python3
"""Docker HEALTHCHECK entry point.
Exits 0 if the seafile daemon and its RPC socket both respond, 1 otherwise.
Runs as a separate, short-lived process on every check, so it needs no
credentials and touches no state the main process owns.
"""
import sys
from dsc import const
from dsc.client import SeafileClient
def main() -> int:
client = SeafileClient(host="unused", user="unused", app_dir=const.DEFAULT_APP_DIR)
return 0 if client.is_healthy() else 1
if __name__ == "__main__":
sys.exit(main())
+36
View File
@@ -119,6 +119,42 @@ else
fi
docker rm -f dsc-smoke >/dev/null
echo "== the HEALTHCHECK command through the real entrypoint"
# Runs through main(), unlike the fixture above: HOME/UID/GID come from the
# same drop_privileges() path a real deployment uses, so .ccnet ends up
# under /dsc like it does in production, not under root's home directory.
docker rm -f dsc-smoke >/dev/null 2>&1 || true
docker run -d $PLATFORM_ARG --name dsc-smoke \
-e SERVER_HOST=example.invalid -e USERNAME=u -e PASSWORD=p -e LIBRARY_ID=x \
"$IMAGE" >/dev/null
ready=0
for _ in $(seq 1 30); do
if docker logs dsc-smoke 2>&1 | grep -q "Seafile daemon is ready"; then ready=1; break; fi
sleep 1
done
if [ "$ready" -eq 0 ]; then
fail "the daemon never became ready, HEALTHCHECK not measured"
else
if docker exec dsc-smoke runuser -u seafile -- python3 /dsc/healthcheck.py; then
pass "HEALTHCHECK command exits 0 while the daemon is up"
else
fail "HEALTHCHECK command did not exit 0 while the daemon was up"
fi
fi
docker rm -f dsc-smoke >/dev/null
echo "== the HEALTHCHECK command reports unhealthy when the daemon is down"
docker rm -f dsc-smoke >/dev/null 2>&1 || true
docker run -d $PLATFORM_ARG --name dsc-smoke "$IMAGE" sleep 60 >/dev/null
if docker exec dsc-smoke runuser -u seafile -- python3 /dsc/healthcheck.py; then
fail "HEALTHCHECK command exited 0 with no daemon running"
else
pass "HEALTHCHECK command exits nonzero with no daemon running"
fi
docker rm -f dsc-smoke >/dev/null
echo "== configuration errors stop the container with a useful message"
out=$(docker run --rm $PLATFORM_ARG -e SERVER_HOST=h -e USERNAME=u -e LIBRARY_ID=x "$IMAGE" 2>&1; echo "rc=$?")
check "no credentials exits 2" "rc=2" "$(echo "$out" | tail -1)"
+40
View File
@@ -0,0 +1,40 @@
"""The container HEALTHCHECK must reflect both the daemon and its RPC socket."""
import pytest
from dsc.client import SeafileClient
@pytest.fixture
def client():
return SeafileClient("seafile.example", "user", "pw", "/dsc", token="tok-en")
def test_healthy_when_daemon_and_rpc_both_respond(client, monkeypatch):
monkeypatch.setattr(type(client), "daemon_ready", True)
monkeypatch.setattr(client.rpc, "get_repo_list", lambda a, b: [])
assert client.is_healthy() is True
def test_unhealthy_when_the_daemon_is_not_ready(client, monkeypatch):
monkeypatch.setattr(type(client), "daemon_ready", False)
assert client.is_healthy() is False
def test_unhealthy_when_the_rpc_socket_raises(client, monkeypatch):
monkeypatch.setattr(type(client), "daemon_ready", True)
def broken(a, b):
raise ConnectionError("socket gone")
monkeypatch.setattr(client.rpc, "get_repo_list", broken)
assert client.is_healthy() is False
def test_rpc_is_not_checked_when_the_daemon_is_already_down(client, monkeypatch):
"""A dead daemon means a dead socket too; no need to touch it."""
monkeypatch.setattr(type(client), "daemon_ready", False)
called = []
monkeypatch.setattr(client.rpc, "get_repo_list", lambda a, b: called.append(1))
assert client.is_healthy() is False
assert not called