feat: replace bind-mount log pipeline with Docker log streaming
Services now print RFC 5424 to stdout; Docker captures via json-file driver. A new host-side collector (decnet.web.collector) streams docker logs from all running decky service containers and writes RFC 5424 + parsed JSON to the host log file. The existing ingester continues to tail the .json file unchanged. rsyslog can consume the .log file independently — no DECNET involvement needed. Removes: bind-mount volume injection, _LOG_NETWORK bridge, log_target config field and --log-target CLI flag, TCP syslog forwarding from service templates.
This commit is contained in:
101
tests/test_collector.py
Normal file
101
tests/test_collector.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Tests for the host-side Docker log collector."""
|
||||
|
||||
import json
|
||||
from decnet.web.collector import parse_rfc5424, is_service_container
|
||||
|
||||
|
||||
class TestParseRfc5424:
|
||||
def _make_line(self, fields_str="", msg=""):
|
||||
sd = f"[decnet@55555 {fields_str}]" if fields_str else "-"
|
||||
suffix = f" {msg}" if msg else ""
|
||||
return f"<134>1 2024-01-15T12:00:00+00:00 decky-01 http - request {sd}{suffix}"
|
||||
|
||||
def test_returns_none_for_non_decnet_line(self):
|
||||
assert parse_rfc5424("not a syslog line") is None
|
||||
|
||||
def test_returns_none_for_empty_line(self):
|
||||
assert parse_rfc5424("") is None
|
||||
|
||||
def test_parses_basic_fields(self):
|
||||
line = self._make_line()
|
||||
result = parse_rfc5424(line)
|
||||
assert result is not None
|
||||
assert result["decky"] == "decky-01"
|
||||
assert result["service"] == "http"
|
||||
assert result["event_type"] == "request"
|
||||
|
||||
def test_parses_structured_data_fields(self):
|
||||
line = self._make_line('src_ip="1.2.3.4" method="GET" path="/login"')
|
||||
result = parse_rfc5424(line)
|
||||
assert result is not None
|
||||
assert result["fields"]["src_ip"] == "1.2.3.4"
|
||||
assert result["fields"]["method"] == "GET"
|
||||
assert result["fields"]["path"] == "/login"
|
||||
|
||||
def test_extracts_attacker_ip_from_src_ip(self):
|
||||
line = self._make_line('src_ip="10.0.0.5"')
|
||||
result = parse_rfc5424(line)
|
||||
assert result["attacker_ip"] == "10.0.0.5"
|
||||
|
||||
def test_extracts_attacker_ip_from_src(self):
|
||||
line = self._make_line('src="10.0.0.5"')
|
||||
result = parse_rfc5424(line)
|
||||
assert result["attacker_ip"] == "10.0.0.5"
|
||||
|
||||
def test_attacker_ip_defaults_to_unknown(self):
|
||||
line = self._make_line('user="admin"')
|
||||
result = parse_rfc5424(line)
|
||||
assert result["attacker_ip"] == "Unknown"
|
||||
|
||||
def test_parses_msg(self):
|
||||
line = self._make_line(msg="hello world")
|
||||
result = parse_rfc5424(line)
|
||||
assert result["msg"] == "hello world"
|
||||
|
||||
def test_nilvalue_sd_with_msg(self):
|
||||
line = "<134>1 2024-01-15T12:00:00+00:00 decky-01 http - request - some message"
|
||||
result = parse_rfc5424(line)
|
||||
assert result is not None
|
||||
assert result["msg"] == "some message"
|
||||
assert result["fields"] == {}
|
||||
|
||||
def test_raw_line_preserved(self):
|
||||
line = self._make_line('src_ip="1.2.3.4"')
|
||||
result = parse_rfc5424(line)
|
||||
assert result["raw_line"] == line
|
||||
|
||||
def test_timestamp_formatted(self):
|
||||
line = self._make_line()
|
||||
result = parse_rfc5424(line)
|
||||
assert result["timestamp"] == "2024-01-15 12:00:00"
|
||||
|
||||
def test_unescapes_sd_values(self):
|
||||
line = self._make_line(r'path="/foo\"bar"')
|
||||
result = parse_rfc5424(line)
|
||||
assert result["fields"]["path"] == '/foo"bar'
|
||||
|
||||
def test_result_json_serializable(self):
|
||||
line = self._make_line('src_ip="1.2.3.4" username="admin" password="s3cr3t"')
|
||||
result = parse_rfc5424(line)
|
||||
# Should not raise
|
||||
json.dumps(result)
|
||||
|
||||
|
||||
class TestIsServiceContainer:
|
||||
def test_service_container_returns_true(self):
|
||||
assert is_service_container("decky-01-http") is True
|
||||
assert is_service_container("decky-02-mysql") is True
|
||||
assert is_service_container("decky-99-ssh") is True
|
||||
|
||||
def test_base_container_returns_false(self):
|
||||
assert is_service_container("decky-01") is False
|
||||
assert is_service_container("decky-02") is False
|
||||
|
||||
def test_unrelated_container_returns_false(self):
|
||||
assert is_service_container("nginx") is False
|
||||
assert is_service_container("postgres") is False
|
||||
assert is_service_container("") is False
|
||||
|
||||
def test_strips_leading_slash(self):
|
||||
assert is_service_container("/decky-01-http") is True
|
||||
assert is_service_container("/decky-01") is False
|
||||
@@ -62,41 +62,22 @@ class TestDecnetConfig:
|
||||
)
|
||||
assert cfg.mode == "unihost"
|
||||
|
||||
def test_valid_log_target(self):
|
||||
def test_log_file_field(self):
|
||||
cfg = DecnetConfig(
|
||||
mode="unihost", interface="eth0",
|
||||
subnet="10.0.0.0/24", gateway="10.0.0.1",
|
||||
deckies=[self._base_decky()],
|
||||
log_target="192.168.1.5:5140",
|
||||
log_file="/var/log/decnet/decnet.log",
|
||||
)
|
||||
assert cfg.log_target == "192.168.1.5:5140"
|
||||
assert cfg.log_file == "/var/log/decnet/decnet.log"
|
||||
|
||||
def test_none_log_target_ok(self):
|
||||
def test_log_file_defaults_to_none(self):
|
||||
cfg = DecnetConfig(
|
||||
mode="unihost", interface="eth0",
|
||||
subnet="10.0.0.0/24", gateway="10.0.0.1",
|
||||
deckies=[self._base_decky()],
|
||||
log_target=None,
|
||||
)
|
||||
assert cfg.log_target is None
|
||||
|
||||
def test_invalid_log_target_no_port(self):
|
||||
with pytest.raises(Exception):
|
||||
DecnetConfig(
|
||||
mode="unihost", interface="eth0",
|
||||
subnet="10.0.0.0/24", gateway="10.0.0.1",
|
||||
deckies=[self._base_decky()],
|
||||
log_target="192.168.1.5",
|
||||
)
|
||||
|
||||
def test_invalid_log_target_non_digit_port(self):
|
||||
with pytest.raises(Exception):
|
||||
DecnetConfig(
|
||||
mode="unihost", interface="eth0",
|
||||
subnet="10.0.0.0/24", gateway="10.0.0.1",
|
||||
deckies=[self._base_decky()],
|
||||
log_target="192.168.1.5:abc",
|
||||
)
|
||||
assert cfg.log_file is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -118,7 +99,6 @@ def _sample_config():
|
||||
distro="debian", base_image="debian", hostname="host-01",
|
||||
)
|
||||
],
|
||||
log_target="10.0.0.1:5140",
|
||||
)
|
||||
|
||||
|
||||
@@ -132,7 +112,6 @@ def test_save_and_load_state(tmp_path):
|
||||
loaded_cfg, loaded_compose = result
|
||||
assert loaded_cfg.mode == "unihost"
|
||||
assert loaded_cfg.deckies[0].name == "decky-01"
|
||||
assert loaded_cfg.log_target == "10.0.0.1:5140"
|
||||
assert loaded_compose == compose
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
"""Tests for log_file volume mount in compose generation."""
|
||||
"""Tests for compose generation — logging block and absence of volume mounts."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
from decnet.composer import _CONTAINER_LOG_DIR, _resolve_log_file, generate_compose
|
||||
from decnet.composer import generate_compose, _DOCKER_LOGGING
|
||||
from decnet.config import DeckyConfig, DecnetConfig
|
||||
from decnet.distros import DISTROS
|
||||
|
||||
@@ -29,68 +26,48 @@ def _make_config(log_file: str | None = None) -> DecnetConfig:
|
||||
)
|
||||
|
||||
|
||||
class TestResolveLogFile:
|
||||
def test_absolute_path(self, tmp_path):
|
||||
log_path = str(tmp_path / "decnet.log")
|
||||
host_dir, container_path = _resolve_log_file(log_path)
|
||||
assert host_dir == str(tmp_path)
|
||||
assert container_path == f"{_CONTAINER_LOG_DIR}/decnet.log"
|
||||
class TestComposeLogging:
|
||||
def test_service_container_has_logging_block(self):
|
||||
config = _make_config()
|
||||
compose = generate_compose(config)
|
||||
fragment = compose["services"]["decky-01-http"]
|
||||
assert "logging" in fragment
|
||||
assert fragment["logging"] == _DOCKER_LOGGING
|
||||
|
||||
def test_relative_path_resolves_to_absolute(self):
|
||||
host_dir, container_path = _resolve_log_file("decnet.log")
|
||||
assert Path(host_dir).is_absolute()
|
||||
assert container_path == f"{_CONTAINER_LOG_DIR}/decnet.log"
|
||||
def test_logging_driver_is_json_file(self):
|
||||
config = _make_config()
|
||||
compose = generate_compose(config)
|
||||
fragment = compose["services"]["decky-01-http"]
|
||||
assert fragment["logging"]["driver"] == "json-file"
|
||||
|
||||
def test_nested_filename_preserved(self, tmp_path):
|
||||
log_path = str(tmp_path / "logs" / "honeypot.log")
|
||||
_, container_path = _resolve_log_file(log_path)
|
||||
assert container_path.endswith("honeypot.log")
|
||||
def test_logging_has_rotation_options(self):
|
||||
config = _make_config()
|
||||
compose = generate_compose(config)
|
||||
fragment = compose["services"]["decky-01-http"]
|
||||
opts = fragment["logging"]["options"]
|
||||
assert "max-size" in opts
|
||||
assert "max-file" in opts
|
||||
|
||||
def test_base_container_has_no_logging_block(self):
|
||||
"""Base containers run sleep infinity and produce no app logs."""
|
||||
config = _make_config()
|
||||
compose = generate_compose(config)
|
||||
base = compose["services"]["decky-01"]
|
||||
assert "logging" not in base
|
||||
|
||||
class TestComposeLogFileMount:
|
||||
def test_no_log_file_no_volume(self):
|
||||
config = _make_config(log_file=None)
|
||||
def test_no_volume_mounts_on_service_container(self):
|
||||
config = _make_config(log_file="/tmp/decnet.log")
|
||||
compose = generate_compose(config)
|
||||
fragment = compose["services"]["decky-01-http"]
|
||||
assert not fragment.get("volumes")
|
||||
|
||||
def test_no_decnet_log_file_env_var(self):
|
||||
config = _make_config(log_file="/tmp/decnet.log")
|
||||
compose = generate_compose(config)
|
||||
fragment = compose["services"]["decky-01-http"]
|
||||
assert "DECNET_LOG_FILE" not in fragment.get("environment", {})
|
||||
volumes = fragment.get("volumes", [])
|
||||
assert not any(_CONTAINER_LOG_DIR in v for v in volumes)
|
||||
|
||||
def test_log_file_sets_env_var(self, tmp_path):
|
||||
config = _make_config(log_file=str(tmp_path / "decnet.log"))
|
||||
def test_no_log_network_in_networks(self):
|
||||
config = _make_config()
|
||||
compose = generate_compose(config)
|
||||
fragment = compose["services"]["decky-01-http"]
|
||||
env = fragment["environment"]
|
||||
assert "DECNET_LOG_FILE" in env
|
||||
assert env["DECNET_LOG_FILE"].startswith(_CONTAINER_LOG_DIR)
|
||||
assert env["DECNET_LOG_FILE"].endswith("decnet.log")
|
||||
|
||||
def test_log_file_adds_volume_mount(self, tmp_path):
|
||||
config = _make_config(log_file=str(tmp_path / "decnet.log"))
|
||||
compose = generate_compose(config)
|
||||
fragment = compose["services"]["decky-01-http"]
|
||||
volumes = fragment.get("volumes", [])
|
||||
assert any(_CONTAINER_LOG_DIR in v for v in volumes)
|
||||
|
||||
def test_volume_mount_format(self, tmp_path):
|
||||
config = _make_config(log_file=str(tmp_path / "decnet.log"))
|
||||
compose = generate_compose(config)
|
||||
fragment = compose["services"]["decky-01-http"]
|
||||
mount = next(v for v in fragment["volumes"] if _CONTAINER_LOG_DIR in v)
|
||||
host_part, container_part = mount.split(":")
|
||||
assert Path(host_part).is_absolute()
|
||||
assert container_part == _CONTAINER_LOG_DIR
|
||||
|
||||
def test_host_log_dir_created(self, tmp_path):
|
||||
log_dir = tmp_path / "newdir"
|
||||
config = _make_config(log_file=str(log_dir / "decnet.log"))
|
||||
generate_compose(config)
|
||||
assert log_dir.exists()
|
||||
|
||||
def test_volume_not_duplicated(self, tmp_path):
|
||||
"""Same mount must not appear twice even if fragment already has volumes."""
|
||||
config = _make_config(log_file=str(tmp_path / "decnet.log"))
|
||||
compose = generate_compose(config)
|
||||
fragment = compose["services"]["decky-01-http"]
|
||||
log_mounts = [v for v in fragment["volumes"] if _CONTAINER_LOG_DIR in v]
|
||||
assert len(log_mounts) == 1
|
||||
assert "decnet_logs" not in compose["networks"]
|
||||
|
||||
Reference in New Issue
Block a user