Mount host log directory into containers for on-host syslog file
Previously --log-file only set DECNET_LOG_FILE inside containers with no volume mount, so logs were lost on container restart and never visible on the host. Now generate_compose binds the host log dir to /var/log/decnet inside every service container so the file appears on the host as expected. - composer.py: _resolve_log_file() maps host path → container path; generate_compose() mkdir's host dir and injects volume + DECNET_LOG_FILE - tests/test_log_file_mount.py: 9 new tests covering mount format, env injection, dir creation, and dedup Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,13 +16,35 @@ from decnet.config import DecnetConfig
|
||||
from decnet.network import MACVLAN_NETWORK_NAME
|
||||
from decnet.services.registry import get_service
|
||||
|
||||
_CONTAINER_LOG_DIR = "/var/log/decnet"
|
||||
|
||||
_LOG_NETWORK = "decnet_logs"
|
||||
|
||||
|
||||
def _resolve_log_file(log_file: str) -> tuple[str, str]:
|
||||
"""
|
||||
Return (host_dir, container_log_path) for a user-supplied log file path.
|
||||
|
||||
The host path is resolved to absolute so Docker can bind-mount it.
|
||||
All containers share the same host directory, mounted at _CONTAINER_LOG_DIR.
|
||||
"""
|
||||
host_path = Path(log_file).resolve()
|
||||
host_dir = str(host_path.parent)
|
||||
container_path = f"{_CONTAINER_LOG_DIR}/{host_path.name}"
|
||||
return host_dir, container_path
|
||||
|
||||
|
||||
def generate_compose(config: DecnetConfig) -> dict:
|
||||
"""Build and return the full docker-compose data structure."""
|
||||
services: dict = {}
|
||||
|
||||
log_host_dir: str | None = None
|
||||
log_container_path: str | None = None
|
||||
if config.log_file:
|
||||
log_host_dir, log_container_path = _resolve_log_file(config.log_file)
|
||||
# Ensure the host log directory exists so Docker doesn't create it as root-owned
|
||||
Path(log_host_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for decky in config.deckies:
|
||||
base_key = decky.name # e.g. "decky-01"
|
||||
|
||||
@@ -58,8 +80,12 @@ def generate_compose(config: DecnetConfig) -> dict:
|
||||
|
||||
fragment.setdefault("environment", {})
|
||||
fragment["environment"]["HOSTNAME"] = decky.hostname
|
||||
if config.log_file:
|
||||
fragment["environment"]["DECNET_LOG_FILE"] = config.log_file
|
||||
if log_host_dir and log_container_path:
|
||||
fragment["environment"]["DECNET_LOG_FILE"] = log_container_path
|
||||
fragment.setdefault("volumes", [])
|
||||
mount = f"{log_host_dir}:{_CONTAINER_LOG_DIR}"
|
||||
if mount not in fragment["volumes"]:
|
||||
fragment["volumes"].append(mount)
|
||||
|
||||
# Share the base container's network — no own IP needed
|
||||
fragment["network_mode"] = f"service:{base_key}"
|
||||
|
||||
98
test-scan
Normal file
98
test-scan
Normal file
@@ -0,0 +1,98 @@
|
||||
# Nmap 7.92 scan initiated Sat Apr 4 04:36:10 2026 as: nmap -sS -sV -O -A -oN test-scan 192.168.1.110-119
|
||||
Nmap scan report for 192.168.1.110
|
||||
Host is up (0.000044s latency).
|
||||
Not shown: 996 closed tcp ports (reset)
|
||||
PORT STATE SERVICE VERSION
|
||||
25/tcp open smtp Postfix smtpd
|
||||
|_smtp-commands: decky-webmail, PIPELINING, SIZE 10240000, VRFY, ETRN, AUTH PLAIN LOGIN, ENHANCEDSTATUSCODES, 8BITMIME, DSN
|
||||
80/tcp open http Apache httpd 2.4.54 ((Debian))
|
||||
|_http-title: WordPress › Error
|
||||
|_http-server-header: Werkzeug/3.1.8 Python/3.11.2
|
||||
110/tcp open pop3
|
||||
| fingerprint-strings:
|
||||
| GenericLines, HTTPOptions:
|
||||
| +OK decky-webmail POP3 server ready
|
||||
| -ERR Unknown command
|
||||
| -ERR Unknown command
|
||||
| NULL:
|
||||
|_ +OK decky-webmail POP3 server ready
|
||||
|_pop3-capabilities: USER
|
||||
143/tcp open imap
|
||||
| fingerprint-strings:
|
||||
| GenericLines, NULL:
|
||||
| * OK [decky-webmail] IMAP4rev1 Service Ready
|
||||
| GetRequest:
|
||||
| * OK [decky-webmail] IMAP4rev1 Service Ready
|
||||
|_ Command not recognized
|
||||
|_imap-capabilities: AUTH=LOGINA0001 OK IMAP4rev1 completed CAPABILITY AUTH=PLAIN
|
||||
2 services unrecognized despite returning data. If you know the service/version, please submit the following fingerprints at https://nmap.org/cgi-bin/submit.cgi?new-service :
|
||||
==============NEXT SERVICE FINGERPRINT (SUBMIT INDIVIDUALLY)==============
|
||||
SF-Port110-TCP:V=7.92%I=7%D=4/4%Time=69D0BF72%P=x86_64-redhat-linux-gnu%r(
|
||||
SF:NULL,25,"\+OK\x20decky-webmail\x20POP3\x20server\x20ready\r\n")%r(Gener
|
||||
SF:icLines,51,"\+OK\x20decky-webmail\x20POP3\x20server\x20ready\r\n-ERR\x2
|
||||
SF:0Unknown\x20command\r\n-ERR\x20Unknown\x20command\r\n")%r(HTTPOptions,5
|
||||
SF:1,"\+OK\x20decky-webmail\x20POP3\x20server\x20ready\r\n-ERR\x20Unknown\
|
||||
SF:x20command\r\n-ERR\x20Unknown\x20command\r\n");
|
||||
==============NEXT SERVICE FINGERPRINT (SUBMIT INDIVIDUALLY)==============
|
||||
SF-Port143-TCP:V=7.92%I=7%D=4/4%Time=69D0BF72%P=x86_64-redhat-linux-gnu%r(
|
||||
SF:NULL,2E,"\*\x20OK\x20\[decky-webmail\]\x20IMAP4rev1\x20Service\x20Ready
|
||||
SF:\r\n")%r(GetRequest,4E,"\*\x20OK\x20\[decky-webmail\]\x20IMAP4rev1\x20S
|
||||
SF:ervice\x20Ready\r\nGET\x20BAD\x20Command\x20not\x20recognized\r\n")%r(G
|
||||
SF:enericLines,2E,"\*\x20OK\x20\[decky-webmail\]\x20IMAP4rev1\x20Service\x
|
||||
SF:20Ready\r\n");
|
||||
MAC Address: DE:A7:41:91:07:8A (Unknown)
|
||||
Device type: general purpose
|
||||
Running: Linux 5.X
|
||||
OS CPE: cpe:/o:linux:linux_kernel:5
|
||||
OS details: Linux 5.3 - 5.4
|
||||
Network Distance: 1 hop
|
||||
Service Info: Host: decky-webmail
|
||||
|
||||
TRACEROUTE
|
||||
HOP RTT ADDRESS
|
||||
1 0.04 ms 192.168.1.110
|
||||
|
||||
Nmap scan report for 192.168.1.111
|
||||
Host is up (0.000015s latency).
|
||||
Not shown: 998 closed tcp ports (reset)
|
||||
PORT STATE SERVICE VERSION
|
||||
21/tcp open ftp vsftpd (before 2.0.8) or WU-FTPD
|
||||
445/tcp open microsoft-ds
|
||||
| fingerprint-strings:
|
||||
| SMBProgNeg:
|
||||
| SMBr
|
||||
|_ "3DUfw
|
||||
1 service unrecognized despite returning data. If you know the service/version, please submit the following fingerprint at https://nmap.org/cgi-bin/submit.cgi?new-service :
|
||||
SF-Port445-TCP:V=7.92%I=7%D=4/4%Time=69D0BF77%P=x86_64-redhat-linux-gnu%r(
|
||||
SF:SMBProgNeg,51,"\0\0\0M\xffSMBr\0\0\0\0\x80\0\xc0\0\0\0\0\0\0\0\0\0\0\0\
|
||||
SF:0\0\0@\x06\0\0\x01\0\x11\x07\0\x03\x01\0\x01\0\0\xfa\0\0\0\0\x01\0\0\0\
|
||||
SF:0\0p\0\0\0\0\0\0\0\0\0\0\0\0\0\x08\x08\0\x11\"3DUfw\x88");
|
||||
MAC Address: 4A:96:18:DB:DA:38 (Unknown)
|
||||
Device type: general purpose
|
||||
Running: Linux 5.X
|
||||
OS CPE: cpe:/o:linux:linux_kernel:5
|
||||
OS details: Linux 5.3 - 5.4
|
||||
Network Distance: 1 hop
|
||||
Service Info: Host: Twisted
|
||||
|
||||
Host script results:
|
||||
| smb2-security-mode:
|
||||
| 2.0.2:
|
||||
|_ Message signing enabled but not required
|
||||
| smb2-time:
|
||||
| date: 2026-04-04T07:36:29
|
||||
|_ start_date: 2026-04-04T07:36:29
|
||||
| smb-security-mode:
|
||||
| account_used: guest
|
||||
| authentication_level: user
|
||||
| challenge_response: supported
|
||||
|_ message_signing: disabled (dangerous, but default)
|
||||
|_ms-sql-info: ERROR: Script execution failed (use -d to debug)
|
||||
|_clock-skew: mean: -77660d15h48m16s, deviation: 109828d18h09m25s, median: -155321d07h36m32s
|
||||
|
||||
TRACEROUTE
|
||||
HOP RTT ADDRESS
|
||||
1 0.01 ms 192.168.1.111
|
||||
|
||||
OS and Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
|
||||
# Nmap done at Sat Apr 4 04:36:38 2026 -- 10 IP addresses (2 hosts up) scanned in 28.29 seconds
|
||||
97
tests/test_log_file_mount.py
Normal file
97
tests/test_log_file_mount.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Tests for log_file volume mount in compose generation."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.composer import _CONTAINER_LOG_DIR, _resolve_log_file, generate_compose
|
||||
from decnet.config import DeckyConfig, DecnetConfig
|
||||
from decnet.distros import DISTROS
|
||||
|
||||
|
||||
def _make_config(log_file: str | None = None) -> DecnetConfig:
|
||||
profile = DISTROS["debian"]
|
||||
decky = DeckyConfig(
|
||||
name="decky-01",
|
||||
ip="10.0.0.10",
|
||||
services=["http"],
|
||||
distro="debian",
|
||||
base_image=profile.image,
|
||||
build_base=profile.build_base,
|
||||
hostname="test-host",
|
||||
)
|
||||
return DecnetConfig(
|
||||
mode="unihost",
|
||||
interface="eth0",
|
||||
subnet="10.0.0.0/24",
|
||||
gateway="10.0.0.1",
|
||||
deckies=[decky],
|
||||
log_file=log_file,
|
||||
)
|
||||
|
||||
|
||||
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"
|
||||
|
||||
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_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")
|
||||
|
||||
|
||||
class TestComposeLogFileMount:
|
||||
def test_no_log_file_no_volume(self):
|
||||
config = _make_config(log_file=None)
|
||||
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"))
|
||||
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
|
||||
Reference in New Issue
Block a user