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:
2026-04-10 00:14:14 -04:00
parent 8d023147cc
commit 25ba3fb56a
11 changed files with 368 additions and 328 deletions

View File

@@ -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"]