* Handle temporary RPC failures and daemon restarts without corrupting state
watch_status()'s per-iteration RPC read had no error handling: an
unexpected failure (the daemon crashing or restarting mid-call, or any
other transient RPC hiccup) propagated straight out of the loop and
crashed the whole container, indistinguishable from a real fatal
error.
The read is now wrapped: an unexpected exception logs a warning and
retries on the next tick, leaving prev_status untouched so the next
successful read still reports every real change. A stop signal or
another DscError still ends the loop exactly as before - the wrapper
explicitly re-raises DscError first, since GracefulShutdown is one and
must not be swallowed by the broader except below it.
Verified against the real image: a container survives seaf-daemon
being killed outright, logging the failure instead of exiting.
* Log a traceback on RPC failure and strengthen the state test
Copilot review:
- The RPC failure warning logged only the message; add exc_info so a
repeated failure in production is actually debuggable.
- The 'prior state is not corrupted' test asserted the warning fired
but never proved a real change after the failure was still detected
and logged, which is the actual property being claimed.
---------
Co-authored-by: Maxim Shulga <maxim.shulga@alludo.com>
* 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>
* Reject UID or GID zero unless a documented use case requires it
setup_uid() would happily remap the seafile user to UID/GID 0, which
after the previous non-root fix would mean drop_privileges() drops
straight back into root. main() now rejects --uid/SEAFILE_UID and
--gid/SEAFILE_GID being 0 with exit code 2, before setup_uid() runs.
Verified against the real image via tests/smoke.sh.
* Clarify the GID=0 rejection message
Copilot review: 'group root' isn't a commonly used phrase. Name GID 0
as the root group explicitly instead.
---------
Co-authored-by: Maxim Shulga <maxim.shulga@alludo.com>
* Run the long-lived process as the configured non-root user
start.py used to stay root for its whole life; only the individual
seaf-cli/seaf-daemon subprocess calls were unprivileged, via a per-call
runuser wrapper. The orchestrating Python process itself, including the
RPC socket to seaf-daemon and the watch_status() loop, ran as root the
entire time.
main() now drops privileges in-process (dsc.misc.drop_privileges, using
os.initgroups/setgid/setuid) right after setup_uid()/create_dir() -
which still need root to remap the seafile user's UID/GID and chown
/dsc - and before SeafileClient is constructed. Since the process is
already the target user by the time any seaf-cli call happens, runuser
is removed from user_cmd(); it also could not be invoked from an
already non-root process.
Verified against the real image, not just mocked unit tests: a running
container's PID 1 shows Uid: 1000 1000 1000 1000 in /proc/1/status, and
tests/smoke.sh gained a check exercising setup_uid/create_dir/
drop_privileges together against the image's real /etc/passwd.
* Isolate os.environ mutation in drop_privileges test
drop_privileges() writes HOME/USER/LOGNAME directly to os.environ, and
the test asserting that didn't restore them, leaking state into later
tests. monkeypatch.delenv registers the keys so its teardown restores
them regardless of who wrote to os.environ.
---------
Co-authored-by: Maxim Shulga <maxim.shulga@alludo.com>
Two checks could report success without having verified anything. The version
check read the last line of output without looking at the exit status, so an
import failure printed a traceback whose last line was accepted as a version.
The shutdown check waited for the process to reach its watch loop but carried
on regardless, so if the container had already exited it measured the shutdown
of something that was not running.
The version check now requires a zero exit and a version shaped like one. The
shutdown check fails and prints the container log when the watch loop is never
reached. Both were confirmed against an image with a deliberately removed
module: each reports a failure where it previously reported success.
Startup, shutdown and secret handling were only ever checked by hand, and the
unit tests cannot check them: they stub seaf-cli, so nothing exercised the
daemon that actually ships. The GitHub runner is x86_64, which matches the
AppImage, so the image build job can run the real thing.
tests/smoke.sh starts and stops the real daemon, sends SIGTERM through docker
stop and asserts the container exits 0 within the grace period with the daemon
shut down, checks that a configuration error exits 2 naming the variable, that
a secret mounted under /run/secrets is read and stays out of the environment,
and that a default container gets exactly the documented defaults.
The pinned client version is exposed as SEAFILE_CLI_VERSION in the image,
since seaf-cli has no --version flag and there is no package manager entry
for an AppImage.