diff --git a/.gitignore b/.gitignore index e22547dd..55a0aced 100644 --- a/.gitignore +++ b/.gitignore @@ -47,12 +47,15 @@ cpputest_*.xml # compile_commands.json copied to workspace root by CMake Tools extension compile_commands.json -# BDD output (syslog-ng writes here at runtime) +# BDD output (syslog-ng or OTel collector writes here at runtime) Bdd/output/* !Bdd/output/.gitkeep Bdd/junit/* !Bdd/junit/.gitkeep +# OTel Collector binary downloaded by Bdd/otel/Install-OtelCollector.ps1 +Bdd/otel/bin/ + # Python bytecode (BDD steps) __pycache__/ *.pyc diff --git a/Bdd/features/environment.py b/Bdd/features/environment.py index a778a766..cb261508 100644 --- a/Bdd/features/environment.py +++ b/Bdd/features/environment.py @@ -29,9 +29,16 @@ def wait_for_tcp_port_open(host="syslog-ng", port=5514, timeout=5): def before_all(context): + # Configurable via env so the same step code drives Linux (syslog-ng) and + # Windows (OTel Collector) runners with different binary paths and oracle + # output formats. context.example_binary = os.environ.get( - "EXAMPLE_BINARY", "build/debug/Example/ExampleProgram" + "EXAMPLE_BINARY", "build/debug/Example/SolidSyslogExample" ) + context.received_log = os.environ.get( + "RECEIVED_LOG", "Bdd/output/received.log" + ) + context.oracle_format = os.environ.get("ORACLE_FORMAT", "syslog-ng") def after_scenario(context, scenario): diff --git a/Bdd/features/steps/syslog_steps.py b/Bdd/features/steps/syslog_steps.py index 64aa7843..0970a5cb 100644 --- a/Bdd/features/steps/syslog_steps.py +++ b/Bdd/features/steps/syslog_steps.py @@ -1,4 +1,5 @@ import glob +import json import os import re import shutil @@ -11,8 +12,9 @@ from behave import given, when, then from environment import STORE_FILE_PATH, STORE_PATH_PREFIX -RECEIVED_LOG = "Bdd/output/received.log" -EXAMPLE_BINARY = "build/debug/Example/SolidSyslogExample" +# POSIX-only paths used by the @buffered/threaded scenarios. The cross-platform +# scenarios use context.example_binary / context.received_log instead, set in +# environment.before_all from EXAMPLE_BINARY / RECEIVED_LOG / ORACLE_FORMAT. THREADED_BINARY = "build/debug/Example/SolidSyslogThreadedExample" SYSLOG_NG_CTL = "/var/lib/syslog-ng/syslog-ng.ctl" SYSLOG_NG_CONF = "Bdd/syslog-ng/syslog-ng.conf" @@ -51,7 +53,7 @@ def read_last_line(path): return "" -def parse_syslog_line(line): +def parse_syslog_ng_line(line): """Parse a syslog-ng key=value template line into a dict.""" fields = {} for match in re.finditer(r"(\w+)=(\S+)", line): @@ -71,6 +73,63 @@ def parse_syslog_line(line): return fields +def _otel_attribute(attrs, key): + """Pull a single attribute value (str/int) out of the OTel attribute list.""" + for attr in attrs: + if attr.get("key") == key: + value = attr.get("value", {}) + if "stringValue" in value: + return value["stringValue"] + if "intValue" in value: + return str(value["intValue"]) + return None + + +def parse_otel_jsonl_line(line): + """Parse one JSON line from the OTel Collector file exporter into the + same flat dict shape as parse_syslog_ng_line. + + Walking-skeleton scope only: PRIORITY/TIMESTAMP/HOSTNAME/APP_NAME/PROCID. + MSG and STRUCTURED_DATA are not yet extracted — added when the + message_fields and structured_data scenarios are promoted out of + @windows_wip (OTel's body field carries the whole raw RFC 5424 line for + message-less records, so MSG extraction needs verifying against a real + bodied message). + """ + record = json.loads(line) + log = record["resourceLogs"][0]["scopeLogs"][0]["logRecords"][0] + attrs = log.get("attributes", []) + + fields = {} + priority = _otel_attribute(attrs, "priority") + if priority is not None: + fields["PRIORITY"] = priority + hostname = _otel_attribute(attrs, "hostname") + if hostname is not None: + fields["HOSTNAME"] = hostname + appname = _otel_attribute(attrs, "appname") + if appname is not None: + fields["APP_NAME"] = appname + proc_id = _otel_attribute(attrs, "proc_id") + if proc_id is not None: + fields["PROCID"] = proc_id + + # timeUnixNano → ISO 8601 string compatible with datetime.fromisoformat + time_ns = log.get("timeUnixNano") + if time_ns is not None: + seconds = int(time_ns) / 1e9 + fields["TIMESTAMP"] = datetime.fromtimestamp(seconds, tz=timezone.utc).isoformat() + + return fields + + +def parse_oracle_line(line, oracle_format): + """Dispatch to the right parser based on the active oracle.""" + if oracle_format == "otel-jsonl": + return parse_otel_jsonl_line(line) + return parse_syslog_ng_line(line) + + def wait_for_prompt(process, timeout=30): """Read stdout until we see 'SolidSyslog> ', confirming the command completed.""" import select @@ -105,31 +164,41 @@ def send_command(process, command): def wait_for_messages(context, expected_messages): - """Wait for syslog-ng to flush the expected number of new lines.""" + """Wait for the oracle to flush the expected number of new lines.""" + received_log = context.received_log expected_total = context.lines_before + expected_messages deadline = time.monotonic() + 5 - while line_count(RECEIVED_LOG) < expected_total: + while line_count(received_log) < expected_total: if time.monotonic() > deadline: - actual = line_count(RECEIVED_LOG) - context.lines_before + actual = line_count(received_log) - context.lines_before raise AssertionError( - f"syslog-ng received {actual} of {expected_messages} " + f"oracle received {actual} of {expected_messages} " f"messages within 5 seconds" ) time.sleep(0.1) - context.all_lines = read_new_lines(RECEIVED_LOG, context.lines_before) - context.fields = parse_syslog_line(context.all_lines[-1]) + context.all_lines = read_new_lines(received_log, context.lines_before) + context.fields = parse_oracle_line(context.all_lines[-1], context.oracle_format) context.message_count = len(context.all_lines) def run_example(context, extra_args=None, binary=None, expected_messages=1): - """Run an example binary, send messages via stdin, wait for delivery.""" - binary = binary or EXAMPLE_BINARY + """Run the single-task example as a one-shot: write all stdin upfront, + wait for clean exit, then assert the oracle saw the messages. + + Portable across Linux and Windows — no select.select on a pipe fd (which + Windows doesn't support). Single-task example only: every Log call is + synchronous (NullBuffer + Datagram), so "quit" cannot arrive before the + UDP packets have left the socket. + """ + binary = binary or context.example_binary assert os.path.exists(binary), ( f"Example binary not found at {binary} — build with cmake first" ) - cmd = [os.path.join(".", binary)] + # Windows CreateProcess needs an absolute path (or one resolvable via PATH); + # forward-slash relative paths from a bash-launched behave fail otherwise. + cmd = [os.path.abspath(binary)] if extra_args: cmd.extend(extra_args) @@ -142,23 +211,64 @@ def run_example(context, extra_args=None, binary=None, expected_messages=1): ) context.example_pid = process.pid - # Wait for initial prompt - wait_for_prompt(process) + try: + process.communicate(input=f"send {expected_messages}\nquit\n", timeout=15) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + raise - # Send messages and wait for confirmation - send_command(process, f"send {expected_messages}") + assert process.returncode == 0, ( + f"Example binary failed with exit code {process.returncode}" + ) - # Wait for syslog-ng to receive the messages before quitting wait_for_messages(context, expected_messages) - # Clean shutdown — write quit, don't wait for prompt (process exits) - process.stdin.write("quit\n") - process.stdin.flush() - process.wait(timeout=10) - assert process.returncode == 0, ( - f"Example binary failed with exit code {process.returncode}" + +def run_threaded_example(context, extra_args=None, expected_messages=1): + """Run the threaded example using the prompt-based protocol so that the + service thread has time to drain the buffer between "send N" and "quit". + + POSIX-only — uses select.select on a pipe fd, which Windows does not + support. All threaded scenarios are tagged @buffered and excluded from + the Windows runner. + """ + binary = THREADED_BINARY + assert os.path.exists(binary), ( + f"Threaded binary not found at {binary} — build with cmake first" ) + cmd = [os.path.join(".", binary)] + if extra_args: + cmd.extend(extra_args) + + process = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + context.example_pid = process.pid + + try: + wait_for_prompt(process) + send_command(process, f"send {expected_messages}") + wait_for_messages(context, expected_messages) + + process.stdin.write("quit\n") + process.stdin.flush() + process.wait(timeout=10) + assert process.returncode == 0, ( + f"Threaded example failed with exit code {process.returncode}" + ) + finally: + # Don't let an intermediate exception leak the helper into later + # scenarios — kill if it's still running after the assertions above. + if process.poll() is None: + process.kill() + process.wait(timeout=5) + def syslog_ng_reload(): """Send RELOAD to syslog-ng via its Unix control socket.""" @@ -179,12 +289,16 @@ def syslog_ng_swap_config(config_path): @given("syslog-ng is running") def step_syslog_ng_is_running(context): - assert os.path.exists(SYSLOG_NG_CTL), ( - f"syslog-ng control socket not found at {SYSLOG_NG_CTL}" - ) + # Only assert the syslog-ng control socket when syslog-ng is actually the + # active oracle. Other runners (e.g. the OTel Collector on Windows) reuse + # this step text but expose no such socket. + if context.oracle_format == "syslog-ng": + assert os.path.exists(SYSLOG_NG_CTL), ( + f"syslog-ng control socket not found at {SYSLOG_NG_CTL}" + ) # Record current line count so we can detect the new message - context.lines_before = line_count(RECEIVED_LOG) + context.lines_before = line_count(context.received_log) def build_threaded_command(context, transport, no_sd=False): @@ -339,17 +453,17 @@ def step_example_sends_message(context): @when("the threaded example sends a syslog message") def step_threaded_sends_message(context): - run_example(context, binary=THREADED_BINARY) + run_threaded_example(context) @when("the threaded example sends a syslog message with transport {transport}") def step_threaded_sends_with_transport(context, transport): - run_example(context, ["--transport", transport], binary=THREADED_BINARY) + run_threaded_example(context, ["--transport", transport]) @when("the threaded example sends {count:d} syslog messages") def step_threaded_sends_multiple(context, count): - run_example(context, binary=THREADED_BINARY, expected_messages=count) + run_threaded_example(context, expected_messages=count) @when("the example program sends a message with facility {facility:d} and severity {severity:d}") @@ -466,9 +580,9 @@ def step_check_message_count(context, count): @then("syslog-ng receives no more messages") def step_check_no_more_messages(context): - before = line_count(RECEIVED_LOG) + before = line_count(context.received_log) time.sleep(5) - after = line_count(RECEIVED_LOG) + after = line_count(context.received_log) assert after == before, ( f"Expected no more messages, but received {after - before} additional" ) @@ -540,7 +654,7 @@ def step_check_sequential_ids(context, count): f"Expected {count} messages, got {context.message_count}" ) for i, line in enumerate(context.all_lines, start=1): - fields = parse_syslog_line(line) + fields = parse_syslog_ng_line(line) sd = fields.get("STRUCTURED_DATA", "") match = re.search(r'sequenceId="(\d+)"', sd) assert match, ( @@ -586,7 +700,7 @@ def step_client_is_killed(context): def step_check_contiguous_sequence_ids(context): ids = [] for line in context.all_lines: - fields = parse_syslog_line(line) + fields = parse_syslog_ng_line(line) sd = fields.get("STRUCTURED_DATA", "") match = re.search(r'sequenceId="(\d+)"', sd) assert match, ( @@ -607,7 +721,7 @@ def step_check_last_n_contiguous_ids(context, count, start): ) last_n = context.all_lines[-count:] for i, line in enumerate(last_n): - fields = parse_syslog_line(line) + fields = parse_syslog_ng_line(line) sd = fields.get("STRUCTURED_DATA", "") match = re.search(r'sequenceId="(\d+)"', sd) assert match, ( @@ -632,7 +746,7 @@ def step_check_replayed_sequence_ids(context, id_list): # from the previous session (already verified) replayed = context.all_lines[-len(expected):] for i, line in enumerate(replayed): - fields = parse_syslog_line(line) + fields = parse_syslog_ng_line(line) sd = fields.get("STRUCTURED_DATA", "") match = re.search(r'sequenceId="(\d+)"', sd) assert match, ( @@ -648,7 +762,7 @@ def step_check_replayed_sequence_ids(context, id_list): @then("the last message has sequenceId {value:d}") def step_check_last_sequence_id(context, value): assert context.all_lines, "No messages received to check last sequenceId" - fields = parse_syslog_line(context.all_lines[-1]) + fields = parse_syslog_ng_line(context.all_lines[-1]) sd = fields.get("STRUCTURED_DATA", "") match = re.search(r'sequenceId="(\d+)"', sd) assert match, ( diff --git a/Bdd/features/syslog.feature b/Bdd/features/syslog.feature index 65c44306..13fefb70 100644 --- a/Bdd/features/syslog.feature +++ b/Bdd/features/syslog.feature @@ -1,4 +1,4 @@ -@udp @windows_wip +@udp Feature: Walking skeleton end-to-end The example program sends an RFC 5424 message via UDP. syslog-ng receives it and writes the parsed fields to a log file. diff --git a/Bdd/otel/Install-OtelCollector.ps1 b/Bdd/otel/Install-OtelCollector.ps1 new file mode 100644 index 00000000..44eecda8 --- /dev/null +++ b/Bdd/otel/Install-OtelCollector.ps1 @@ -0,0 +1,64 @@ +# Downloads otelcol-contrib (the OpenTelemetry Collector Contrib Windows +# binary) used as the Windows BDD oracle, verifies its SHA-256, and extracts +# the executable into Bdd/otel/bin/. +# +# Idempotent: if the binary is already present, the script does nothing. +# Used by both the developer inner loop and the bdd-windows CI job. + +[CmdletBinding()] +param( + [string]$Version = '0.150.1', + [string]$Sha256 = '53466D9A16B0D8BC6CB9A7A24EDA306751AFBD28BB115BEE1D2B11E9AF65A334', + [string]$BinDir +) + +$ErrorActionPreference = 'Stop' + +if ([string]::IsNullOrEmpty($BinDir)) +{ + $BinDir = Join-Path $PSScriptRoot 'bin' +} + +$exePath = Join-Path $BinDir 'otelcol-contrib.exe' +$versionMarker = Join-Path $BinDir 'otelcol-contrib.version' + +# Fast-path only when both the exe and a matching version marker exist; +# otherwise re-download so a stale or hand-replaced binary can't silently +# bypass the pinned $Version / $Sha256. +if ((Test-Path $exePath) -and (Test-Path $versionMarker) -and + ((Get-Content $versionMarker -Raw).Trim() -eq $Version)) +{ + Write-Host "otelcol-contrib v$Version already installed at $exePath" + return +} + +New-Item -ItemType Directory -Force -Path $BinDir | Out-Null + +$archive = "otelcol-contrib_${Version}_windows_amd64.tar.gz" +$url = "https://github.com/open-telemetry/opentelemetry-collector-releases/releases/download/v${Version}/${archive}" +$tarballDst = Join-Path $BinDir $archive + +Write-Host "Downloading $url -> $tarballDst" +Invoke-WebRequest -Uri $url -OutFile $tarballDst -UseBasicParsing + +$actual = (Get-FileHash -Algorithm SHA256 $tarballDst).Hash +if ($actual -ne $Sha256) +{ + Remove-Item $tarballDst -Force + throw "SHA-256 mismatch for $archive`nExpected: $Sha256`nActual: $actual" +} + +# Use the system tar (Windows ships bsdtar at C:\Windows\System32\tar.exe); +# Git Bash's tar gets shadowed in PATH when running from a bash-launched +# PowerShell and mishandles Windows-style -C arguments. +$systemTar = Join-Path $env:SystemRoot 'System32\tar.exe' +Write-Host "Extracting otelcol-contrib.exe via $systemTar" +& $systemTar -xzf $tarballDst -C $BinDir otelcol-contrib.exe +if (-not (Test-Path $exePath)) +{ + throw "Extraction failed: $exePath not present" +} + +Remove-Item $tarballDst -Force +Set-Content -Path $versionMarker -Value $Version -NoNewline +Write-Host "Installed otelcol-contrib v$Version at $exePath" diff --git a/Bdd/otel/config.yaml b/Bdd/otel/config.yaml new file mode 100644 index 00000000..c472f363 --- /dev/null +++ b/Bdd/otel/config.yaml @@ -0,0 +1,28 @@ +# OpenTelemetry Collector Contrib config — Windows BDD oracle. +# Receives RFC 5424 syslog over UDP on 127.0.0.1:5514 and writes each +# parsed log record as a JSON line to Bdd/output/received.jsonl. +# +# Used by the bdd-windows runner as a Windows-native replacement for +# syslog-ng (no WSL/Wine/Docker needed). + +receivers: + syslog: + udp: + listen_address: "127.0.0.1:5514" + protocol: rfc5424 + +exporters: + file: + path: "Bdd/output/received.jsonl" + rotation: + max_megabytes: 10 + max_backups: 1 + +service: + telemetry: + logs: + level: warn + pipelines: + logs: + receivers: [syslog] + exporters: [file] diff --git a/Bdd/requirements.txt b/Bdd/requirements.txt new file mode 100644 index 00000000..de811625 --- /dev/null +++ b/Bdd/requirements.txt @@ -0,0 +1,3 @@ +# Pinned for parity between the Linux behave container image +# (ghcr.io/davidcozens/behave) and the Windows runner. +behave==1.3.3 diff --git a/Example/Common/ExampleAppName.c b/Example/Common/ExampleAppName.c index 04ef7816..ebd749cd 100644 --- a/Example/Common/ExampleAppName.c +++ b/Example/Common/ExampleAppName.c @@ -1,9 +1,31 @@ #include "ExampleAppName.h" #include "SolidSyslogFormatter.h" +#include +#include #include +/* Length of ".exe" — used to recognise and strip the Windows executable + extension so the syslog app name omits it regardless of build platform. */ +enum +{ + EXE_SUFFIX_LENGTH = 4 +}; + static const char* appName; +static size_t appNameLength; + +static bool EndsWithDotExe(const char* name, size_t length) +{ + if (length < EXE_SUFFIX_LENGTH) + { + return false; + } + + const char* suffix = name + length - EXE_SUFFIX_LENGTH; + return (suffix[0] == '.') && (tolower((unsigned char) suffix[1]) == 'e') && (tolower((unsigned char) suffix[2]) == 'x') && + (tolower((unsigned char) suffix[3]) == 'e'); +} void ExampleAppName_Set(const char* argv0) { @@ -26,10 +48,16 @@ void ExampleAppName_Set(const char* argv0) separator = (forwardSlash > backSlash) ? forwardSlash : backSlash; } - appName = (separator != NULL) ? separator + 1 : argv0; + appName = (separator != NULL) ? separator + 1 : argv0; + appNameLength = strlen(appName); + + if (EndsWithDotExe(appName, appNameLength)) + { + appNameLength -= EXE_SUFFIX_LENGTH; + } } void ExampleAppName_Get(struct SolidSyslogFormatter* formatter) { - SolidSyslogFormatter_BoundedString(formatter, appName, strlen(appName)); + SolidSyslogFormatter_BoundedString(formatter, appName, appNameLength); } diff --git a/Tests/Example/ExampleAppNameTest.cpp b/Tests/Example/ExampleAppNameTest.cpp index 189f79dd..89fe6c3a 100644 --- a/Tests/Example/ExampleAppNameTest.cpp +++ b/Tests/Example/ExampleAppNameTest.cpp @@ -30,9 +30,9 @@ TEST_GROUP(ExampleAppName) TEST(ExampleAppName, BackslashSeparatorExtractsBaseName) { - ExampleAppName_Set("dir\\binary.exe"); + ExampleAppName_Set("dir\\binary"); ExampleAppName_Get(formatter); - STRCMP_EQUAL("binary.exe", formatted()); + STRCMP_EQUAL("binary", formatted()); } TEST(ExampleAppName, ForwardSlashSeparatorExtractsBaseName) @@ -51,7 +51,35 @@ TEST(ExampleAppName, NoSeparatorReturnsWholeArgument) TEST(ExampleAppName, MixedSeparatorsUseRightmost) { - ExampleAppName_Set("C:\\msys64\\home/user/example.exe"); + ExampleAppName_Set("C:\\msys64\\home/user/example"); ExampleAppName_Get(formatter); - STRCMP_EQUAL("example.exe", formatted()); + STRCMP_EQUAL("example", formatted()); +} + +TEST(ExampleAppName, ExeExtensionIsStripped) +{ + ExampleAppName_Set("app.exe"); + ExampleAppName_Get(formatter); + STRCMP_EQUAL("app", formatted()); +} + +TEST(ExampleAppName, UpperCaseExeExtensionIsStripped) +{ + ExampleAppName_Set("APP.EXE"); + ExampleAppName_Get(formatter); + STRCMP_EQUAL("APP", formatted()); +} + +TEST(ExampleAppName, ExeWithPathSeparatorIsStripped) +{ + ExampleAppName_Set("C:\\bin\\SolidSyslogExample.exe"); + ExampleAppName_Get(formatter); + STRCMP_EQUAL("SolidSyslogExample", formatted()); +} + +TEST(ExampleAppName, NonExeExtensionIsKept) +{ + ExampleAppName_Set("data.txt"); + ExampleAppName_Get(formatter); + STRCMP_EQUAL("data.txt", formatted()); } diff --git a/docs/bdd.md b/docs/bdd.md index 3633de0b..d07ed0dc 100644 --- a/docs/bdd.md +++ b/docs/bdd.md @@ -131,7 +131,56 @@ Runner tag filters: | Runner | Filter | | --- | --- | | Linux (syslog-ng) | `not @wip` | -| Windows (planned) | `not @wip and not @windows_wip and not @buffered` | +| Windows (OTel Collector) | `not @wip and not @windows_wip and not @buffered` | + +## Two oracles, one step file + +Step definitions read the active oracle from `ORACLE_FORMAT`: + +| Oracle | `ORACLE_FORMAT` | `RECEIVED_LOG` default | Runs on | +| --- | --- | --- | --- | +| syslog-ng (key=value text) | `syslog-ng` | `Bdd/output/received.log` | Linux container | +| OTel Collector Contrib (JSON Lines) | `otel-jsonl` | `Bdd/output/received.jsonl` | Windows native | + +`parse_oracle_line` dispatches to the right parser; both produce the same flat field dict +(`PRIORITY`, `TIMESTAMP`, `HOSTNAME`, `APP_NAME`, `PROCID`, ...) so the `Then` steps don't +need to know which oracle is in use. Walking-skeleton scope only — `STRUCTURED_DATA` +re-rendering for the OTel parser is added when the structured-data scenarios are +promoted out of `@windows_wip`. + +## Local Windows BDD setup + +Prerequisites: Python 3.13+ on `PATH` (`winget install Python.Python.3.13`), MSVC + vcpkg +for the `msvc-debug` build. + +> **Shell:** the recipe below uses bash syntax (background `&`, inline `VAR=value command`). +> Run it from **Git Bash for Windows**, which ships with the Git installer that's already +> required for this repo. PowerShell users either translate (`Start-Process` for backgrounding, +> `$env:VAR = "..."` for env vars) or invoke `bash -c "..."`. + +```pwsh +# 1. Install behave (pinned version matches the Linux container image) +pip install -r Bdd/requirements.txt + +# 2. Download otelcol-contrib (pinned version, SHA-256 verified) +powershell -ExecutionPolicy Bypass -File Bdd/otel/Install-OtelCollector.ps1 + +# 3. Build the Windows example +cmake --preset msvc-debug +cmake --build --preset msvc-debug --target SolidSyslogWindowsExample + +# 4. Start the OTel oracle (binds 127.0.0.1:5514) +./Bdd/otel/bin/otelcol-contrib.exe --config=Bdd/otel/config.yaml & + +# 5. Run the Windows-eligible scenarios +EXAMPLE_BINARY=build/msvc-debug/Example/Debug/SolidSyslogExample.exe \ +RECEIVED_LOG=Bdd/output/received.jsonl \ +ORACLE_FORMAT=otel-jsonl \ +behave --tags='not @wip and not @windows_wip and not @buffered' Bdd/features/ +``` + +If port 5514 is busy, stop the dev-container's syslog-ng: +`docker compose -f .devcontainer/docker-compose.yml stop syslog-ng` ## Test isolation