The password could only be given through the PASSWORD variable, so Docker Compose and Swarm secrets were unusable and the password sat in the container environment. PASSWORD_FILE and TOKEN_FILE read a secret from a mounted file. Only trailing newlines are stripped, since secret files are usually written with one and a password that silently carries it just fails to authenticate; everything else, including surrounding spaces, belongs to the secret. Setting both a variable and a file for one secret is rejected rather than resolved by precedence, because the two are meant to be the same value and quietly picking one hides the mistake until authentication fails. The installed seaf-cli supports token authentication and ignores the password when a token is given, so sync now passes -T instead of -p. The account password no longer appears in any command line. The token does, because seaf-cli accepts credentials only as arguments, but a token can be revoked on the server. This is documented in the README. Both secrets are masked before a command is logged. Tests cover newline handling, empty and missing files, conflicting sources, and that neither secret reaches the logs. Requests are now blocked in tests: the client retries with backoff, so a test that reached the network appeared to hang instead of failing.
187 lines
6.1 KiB
Python
187 lines
6.1 KiB
Python
"""Startup and shutdown must be bounded, checked, and interruptible."""
|
|
|
|
import argparse
|
|
import signal
|
|
import subprocess
|
|
import time
|
|
|
|
import pytest
|
|
|
|
import start
|
|
from dsc import client as client_module
|
|
from dsc import const
|
|
from dsc.client import SeafileClient
|
|
from dsc.errors import DaemonError, DaemonTimeout, GracefulShutdown
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def fast_polling(monkeypatch):
|
|
"""Keep the bounded waits bounded to milliseconds in tests."""
|
|
monkeypatch.setattr(const, "DAEMON_POLL_PERIOD", 0)
|
|
monkeypatch.setattr(const, "DAEMON_START_TIMEOUT", 0.05)
|
|
monkeypatch.setattr(const, "DAEMON_STOP_TIMEOUT", 0.05)
|
|
|
|
|
|
@pytest.fixture
|
|
def client(monkeypatch):
|
|
monkeypatch.setattr(client_module, "create_dir", lambda path: None)
|
|
# A token keeps the tests off the network: without one, authentication
|
|
# would be attempted against the server before syncing.
|
|
return SeafileClient("seafile.example", "user", "pw", "/dsc", token="tok-en")
|
|
|
|
|
|
def fake_runner(returncodes, monkeypatch, stdout=b""):
|
|
"""Return exit codes by seaf-cli subcommand; record every call."""
|
|
calls = []
|
|
|
|
def fake_run(argv, **kwargs):
|
|
calls.append(argv)
|
|
subcommand = argv[argv.index("seaf-cli") + 1]
|
|
code = returncodes.get(subcommand, 0)
|
|
return subprocess.CompletedProcess(argv, code, stdout=stdout)
|
|
|
|
monkeypatch.setattr(subprocess, "run", fake_run)
|
|
return calls
|
|
|
|
|
|
def test_start_daemon_fails_when_the_command_fails(client, monkeypatch):
|
|
fake_runner({"start": 1}, monkeypatch)
|
|
with pytest.raises(DaemonError):
|
|
client.start_daemon()
|
|
|
|
|
|
def test_init_config_fails_when_the_command_fails(client, monkeypatch):
|
|
monkeypatch.setattr(type(client), "config_initialized", property(lambda self: False))
|
|
fake_runner({"init": 2}, monkeypatch)
|
|
with pytest.raises(DaemonError):
|
|
client.init_config()
|
|
|
|
|
|
def test_start_daemon_gives_up_when_the_daemon_never_becomes_ready(client, monkeypatch):
|
|
# "start" succeeds but "status" never does: the daemon never comes up.
|
|
fake_runner({"status": 1}, monkeypatch)
|
|
|
|
started = time.monotonic()
|
|
with pytest.raises(DaemonTimeout) as err:
|
|
client.start_daemon()
|
|
|
|
assert time.monotonic() - started < 5, "the wait must be bounded"
|
|
assert "ready" in str(err.value).lower()
|
|
|
|
|
|
def test_start_daemon_returns_once_ready(client, monkeypatch):
|
|
fake_runner({}, monkeypatch) # status returns 0 straight away
|
|
client.start_daemon()
|
|
|
|
|
|
def test_stop_daemon_gives_up_when_the_daemon_never_stops(client, monkeypatch):
|
|
# "status" keeps succeeding, so the daemon never reports itself stopped.
|
|
fake_runner({}, monkeypatch)
|
|
|
|
started = time.monotonic()
|
|
with pytest.raises(DaemonTimeout):
|
|
client.stop_daemon()
|
|
|
|
assert time.monotonic() - started < 5, "the wait must be bounded"
|
|
|
|
|
|
def test_stop_daemon_succeeds_when_the_daemon_is_already_gone(client, monkeypatch):
|
|
# A stopped daemon makes both "stop" and "status" fail; that is success.
|
|
fake_runner({"stop": 1, "status": 1}, monkeypatch)
|
|
client.stop_daemon()
|
|
|
|
|
|
def test_stop_timeout_fits_inside_the_docker_stop_grace_period():
|
|
"""docker stop sends SIGKILL 10s after SIGTERM by default."""
|
|
assert const.DAEMON_STOP_TIMEOUT < 10
|
|
|
|
|
|
def test_configure_fails_when_setting_an_option_fails(client, monkeypatch):
|
|
fake_runner({"config": 1}, monkeypatch)
|
|
with pytest.raises(DaemonError):
|
|
client.configure(argparse.Namespace(upload_limit=100), check_for_daemon=False)
|
|
|
|
|
|
def test_sync_lib_reports_a_failure_without_stopping_other_libraries(client, monkeypatch, caplog):
|
|
fake_runner({"sync": 1}, monkeypatch)
|
|
with caplog.at_level("ERROR"):
|
|
assert client.sync_lib("lib-id", "/dsc/seafile/Docs") is False
|
|
assert "lib-id" in caplog.text
|
|
|
|
|
|
def test_sync_lib_reports_success(client, monkeypatch):
|
|
fake_runner({}, monkeypatch)
|
|
assert client.sync_lib("lib-id", "/dsc/seafile/Docs") is True
|
|
|
|
|
|
class FakeClient:
|
|
"""A client whose lifecycle calls are recorded instead of executed."""
|
|
|
|
def __init__(self, fail_on=None, error=None):
|
|
self.fail_on = fail_on
|
|
self.error = error or DaemonError("boom")
|
|
self.calls = []
|
|
self.remote_libraries = {"lib-id": "Docs"}
|
|
|
|
def __getattr__(self, name):
|
|
def record(*args, **kwargs):
|
|
self.calls.append(name)
|
|
if name == self.fail_on:
|
|
raise self.error
|
|
return True
|
|
return record
|
|
|
|
def __record(self, name):
|
|
self.calls.append(name)
|
|
if name == self.fail_on:
|
|
raise self.error
|
|
|
|
def get_library_id(self, name):
|
|
self.__record("get_library_id")
|
|
return "lib-id"
|
|
|
|
def get_local_libraries(self):
|
|
self.__record("get_local_libraries")
|
|
return set()
|
|
|
|
|
|
ARGS = argparse.Namespace(libraries="Docs", server="seafile.example")
|
|
|
|
|
|
def test_run_stops_the_daemon_after_a_normal_finish():
|
|
client = FakeClient()
|
|
assert start.run(client, ARGS, "/dsc/seafile") == 0
|
|
assert client.calls[-1] == "stop_daemon"
|
|
|
|
|
|
@pytest.mark.parametrize("failing_step", [
|
|
"init_config", "start_daemon", "configure", "get_local_libraries", "watch_status",
|
|
])
|
|
def test_run_returns_nonzero_and_still_stops_the_daemon_on_failure(failing_step):
|
|
client = FakeClient(fail_on=failing_step)
|
|
assert start.run(client, ARGS, "/dsc/seafile") != 0
|
|
assert client.calls[-1] == "stop_daemon"
|
|
|
|
|
|
def test_run_returns_zero_and_stops_the_daemon_on_shutdown_signal():
|
|
client = FakeClient(fail_on="watch_status", error=GracefulShutdown("SIGTERM"))
|
|
assert start.run(client, ARGS, "/dsc/seafile") == 0
|
|
assert client.calls[-1] == "stop_daemon"
|
|
|
|
|
|
def test_run_reports_failure_even_if_stopping_also_fails():
|
|
client = FakeClient(fail_on="stop_daemon", error=DaemonTimeout("stuck"))
|
|
assert start.run(client, ARGS, "/dsc/seafile") != 0
|
|
|
|
|
|
@pytest.mark.parametrize("signum", [signal.SIGTERM, signal.SIGINT])
|
|
def test_signal_handlers_raise_a_graceful_shutdown(signum):
|
|
previous = signal.getsignal(signum)
|
|
try:
|
|
start.install_signal_handlers()
|
|
handler = signal.getsignal(signum)
|
|
with pytest.raises(GracefulShutdown):
|
|
handler(signum, None)
|
|
finally:
|
|
signal.signal(signum, previous)
|