docs: validate marked config samples

Why:
Documentation examples can drift from accepted configuration syntax.
The old hand-copied test approach checked fixtures instead of the
rendered documentation source.

Impact:
Marked documentation samples are syntax-checked in docs CI and report
coverage through the docs-samples Codecov flag.

Before/After:
Before, doc examples relied on manual review or copied tests. After,
marked RST rsyslog blocks are extracted and checked by rsyslogd -C -N1.

Technical Overview:
Add a documentation sample validator that scans doc/source for
rsyslog-doc-sample markers and validates the following rsyslog code
block with the built rsyslogd.
Support metadata for plugin requirements and generated prepend/append
fixture lines.
Wire the documentation workflow to build an instrumented rsyslogd, run
the validator, generate lcov output, and upload Codecov coverage under
the docs-samples flag.
Register the validator and its tests in the documentation dist list.
Extend local validation planning to build and run the validator when
marked documentation samples are present.

With the help of AI-Agents: OpenAI Codex
This commit is contained in:
Rainer Gerhards 2026-07-06 14:25:02 +02:00
parent 7a4cd1fb50
commit 8fe4a208d1
7 changed files with 695 additions and 0 deletions

View File

@ -39,6 +39,8 @@ jobs:
doc/source/conf.py
doc/tools/check-html-links.py
doc/tools/fix-mermaid-offline.py
doc/tools/test_validate_doc_samples.py
doc/tools/validate-doc-samples.py
# we build from tarball to ensure no missing files!
- name: setup make dist build env
@ -62,6 +64,7 @@ jobs:
libtool-bin \
libyaml-dev \
libzstd-dev \
lcov \
lsof \
make \
net-tools \
@ -128,6 +131,8 @@ jobs:
steps.doc_changes.outputs.any_changed == 'true' }}
run: |
cd doc-builder
CFLAGS='-g -O0 --coverage -fprofile-update=atomic' \
LDFLAGS='--coverage' \
./configure --enable-silent-rules --disable-testbench \
--disable-imdiag --disable-imdocker --disable-imfile \
--disable-default-tests --disable-impstats --disable-impstats-push --disable-imptcp \
@ -146,8 +151,61 @@ jobs:
--disable-omkafka --disable-imkafka --disable-ommongodb \
--disable-omrabbitmq --disable-journal-tests --disable-mmdarwin \
--disable-helgrind --disable-uuid --disable-fmhttp
make -j10
make -C doc html SPHINXOPTS="-j8 -W -q --keep-going"
- name: Validate documentation config samples
if: >-
${{ github.event_name != 'pull_request' ||
steps.doc_changes.outputs.any_changed == 'true' }}
run: |
cd doc-builder
python3 doc/tools/validate-doc-samples.py \
--source-dir doc/source \
--build-dir . \
--work-dir doc/build/doc-sample-validation
- name: Generate documentation sample coverage
if: >-
${{ github.event_name != 'pull_request' ||
steps.doc_changes.outputs.any_changed == 'true' }}
run: |
cd doc-builder
lcov --capture --directory . \
--rc geninfo_unexecuted_blocks=1 \
--ignore-errors mismatch \
--output-file coverage.raw.info
lcov --remove coverage.raw.info '/usr/*' '*/tests/*' '*/doc/tools/*' \
--ignore-errors unused,unused \
-o coverage.info
rm -f coverage.raw.info
lcov --list coverage.info | sed -n '1,60p' || true
[ -s coverage.info ] || { echo "coverage.info is empty"; exit 1; }
- name: Upload documentation sample coverage to Codecov
if: >-
${{ (github.event_name != 'pull_request' ||
steps.doc_changes.outputs.any_changed == 'true') &&
(github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository) }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: doc-builder/coverage.info
flags: docs-samples
fail_ci_if_error: true
- name: Upload fork documentation sample coverage to Codecov
if: >-
${{ github.event_name == 'pull_request' &&
steps.doc_changes.outputs.any_changed == 'true' &&
github.event.pull_request.head.repo.full_name != github.repository }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
files: doc-builder/coverage.info
flags: docs-samples
fail_ci_if_error: true
- name: Validate generated HTML links
if: >-
${{ github.event_name != 'pull_request' ||

View File

@ -271,6 +271,8 @@ print_plan() {
;;
rendered-docs)
echo " - ./doc/tools/build-doc-linux.sh --clean --format html --jobs \"$doc_jobs\""
echo " - python3 ./doc/tools/validate-doc-samples.py --source-dir doc/source --build-dir . --work-dir doc/build/doc-sample-validation"
echo " after building ./tools/rsyslogd when marked samples are present."
echo " - Add --strict for larger or structural documentation edits."
;;
test-shell-only)
@ -428,6 +430,51 @@ run_docs_build() {
./doc/tools/build-doc-linux.sh --clean --format html --jobs "$doc_jobs"
}
run_doc_sample_validation_if_available() {
if [ ! -f ./doc/tools/validate-doc-samples.py ]; then
echo "warning: doc/tools/validate-doc-samples.py missing; skipping doc sample validation" >&2
return 0
fi
if ! find doc/source -type f -name '*.rst' -exec grep -q \
'^[[:space:]]*\.\. rsyslog-doc-sample:[[:space:]]*validate-config[[:space:]]*$' {} +; then
echo "no marked doc samples found; skipping doc sample validation"
return 0
fi
for tool in autoreconf make python3; do
if ! command -v "$tool" >/dev/null 2>&1; then
echo "warning: $tool not installed; skipping doc sample validation" >&2
return 0
fi
done
echo "building ./tools/rsyslogd for local doc sample validation"
autoreconf -fvi
CFLAGS='-g -O0 --coverage -fprofile-update=atomic' \
LDFLAGS='--coverage' \
./configure --enable-silent-rules --disable-testbench \
--disable-imdiag --disable-imdocker --disable-imfile \
--disable-default-tests --disable-impstats --disable-impstats-push --disable-imptcp \
--disable-mmanon --disable-mmaudit --disable-mmfields \
--disable-mmjsonparse --disable-mmpstrucdata \
--disable-mmsequence --disable-mmutf8fix --disable-mail \
--disable-omprog --disable-improg --disable-omruleset \
--disable-omstdout --disable-omuxsock \
--disable-pmaixforwardedfrom --disable-pmciscoios \
--disable-pmcisconames --disable-pmlastmsg --disable-pmsnare \
--disable-libgcrypt --disable-mmnormalize \
--disable-omudpspoof --disable-relp --disable-mmsnmptrapd \
--disable-gnutls --disable-usertools --disable-mysql \
--disable-valgrind --disable-omjournal --enable-libsystemd \
--disable-mmkubernetes --disable-imjournal \
--disable-omkafka --disable-imkafka --disable-ommongodb \
--disable-omrabbitmq --disable-journal-tests --disable-mmdarwin \
--disable-helgrind --disable-uuid --disable-fmhttp
make -j"$build_jobs"
python3 ./doc/tools/validate-doc-samples.py \
--source-dir doc/source \
--build-dir . \
--work-dir doc/build/doc-sample-validation
}
have_container_tooling() {
if ! command -v docker >/dev/null 2>&1; then
echo "warning: docker not installed; skipping selected local container validation lane" >&2
@ -595,6 +642,7 @@ local-validation-tooling)
;;
rendered-docs)
run_docs_build
run_doc_sample_validation_if_available
;;
test-shell-only)
run_mock_distcheck_if_needed

View File

@ -1358,6 +1358,8 @@ EXTRA_DIST = \
tools/buildenv/tools/help \
tools/buildenv/tools/version-info \
tools/fix-mermaid-offline.py \
tools/test_validate_doc_samples.py \
tools/validate-doc-samples.py \
tools/inside_docker_doc_html.sh \
tools/pages-root-index.html \
tools/pages-robots.txt \

View File

@ -28,12 +28,16 @@ Usage
-----
.. _properties.message.msg-usage:
.. rsyslog-doc-sample: validate-config
.. code-block:: rsyslog
template(name="example" type="list") {
property(name="msg")
}
action(type="omfile" file="/dev/null" template="example")
See also
--------
See :doc:`../../configuration/properties` for the category overview.

View File

@ -121,6 +121,9 @@ syslog message, you have probably created a table "syslog" with a single
column "message". In such a case, a Rainerscript list template might look
like this:
.. rsyslog-doc-sample: validate-config
:append: action(type="omfile" file="/dev/null" template="sqlInsertMessage")
.. code-block:: rsyslog
template(

View File

@ -0,0 +1,272 @@
#!/usr/bin/env python3
"""Unit tests for validate-doc-samples.py."""
import importlib.util
import os
import subprocess
import tempfile
import textwrap
import unittest
from pathlib import Path
SCRIPT = Path(__file__).with_name("validate-doc-samples.py")
def load_validator():
spec = importlib.util.spec_from_file_location("validate_doc_samples", SCRIPT)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
return module
class ValidateDocSamplesTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name)
self.source = self.root / "doc" / "source"
self.source.mkdir(parents=True)
self.build = self.root / "build"
(self.build / "tools").mkdir(parents=True)
(self.build / "runtime" / ".libs").mkdir(parents=True)
(self.build / ".libs").mkdir(parents=True)
self.rsyslogd = self.build / "tools" / "rsyslogd"
self.rsyslogd.write_text(
"#!/bin/sh\n"
"config=\n"
"for arg in \"$@\"; do\n"
" case \"$arg\" in -f*) config=${arg#-f};; esac\n"
"done\n"
"if [ -n \"$config\" ] && grep -q hang \"$config\"; then sleep 5; fi\n"
"if [ -n \"$config\" ] && grep -q invalid \"$config\"; then exit 1; fi\n"
"if [ -n \"$config\" ] && grep -q 'module(load=\"ommissing\")' \"$config\"; then exit 1; fi\n"
"if [ -n \"$config\" ] && grep -q 'module(load=\"omhanging\")' \"$config\"; then sleep 5; fi\n"
"exit 0\n",
encoding="utf-8",
)
self.rsyslogd.chmod(0o755)
def tearDown(self):
self.tmp.cleanup()
def write_rst(self, content):
path = self.source / "sample.rst"
path.write_text(textwrap.dedent(content), encoding="utf-8")
return path
def run_validator(self, *extra_args):
return subprocess.run(
[
"python3",
str(SCRIPT),
"--source-dir",
str(self.source),
"--build-dir",
str(self.build),
"--work-dir",
str(self.root / "work"),
*extra_args,
],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
def test_discovers_marked_rsyslog_block(self):
self.write_rst(
"""
Title
=====
.. rsyslog-doc-sample: validate-config
.. code-block:: rsyslog
template(name="example" type="list") {
property(name="msg")
}
"""
)
validator = load_validator()
samples = validator.discover_samples(self.source)
self.assertEqual(len(samples), 1)
self.assertIn('property(name="msg")', samples[0].code)
def test_discovers_nested_marked_rsyslog_block(self):
self.write_rst(
"""
* List entry:
.. rsyslog-doc-sample: validate-config
.. code-block:: rsyslog
template(name="example" type="list") {
property(name="msg")
}
"""
)
validator = load_validator()
samples = validator.discover_samples(self.source)
self.assertEqual(len(samples), 1)
self.assertTrue(samples[0].code.startswith('template(name="example"'))
self.assertIn(' property(name="msg")', samples[0].code)
def test_ignores_unmarked_blocks(self):
self.write_rst(
"""
.. code-block:: rsyslog
invalid
"""
)
result = self.run_validator()
self.assertNotEqual(result.returncode, 0)
self.assertIn("No marked rsyslog documentation samples found", result.stderr)
def test_required_plugin_skip(self):
self.write_rst(
"""
.. rsyslog-doc-sample: validate-config
:require-plugin: ommissing
.. code-block:: rsyslog
template(name="example" type="list") {
property(name="msg")
}
"""
)
result = self.run_validator()
self.assertEqual(result.returncode, 0)
self.assertIn("1 skipped", result.stdout)
def test_required_plugin_accepts_contrib_module(self):
contrib_module = self.build / "contrib" / "omexample" / ".libs"
contrib_module.mkdir(parents=True)
(contrib_module / "omexample.so").write_text("", encoding="utf-8")
self.write_rst(
"""
.. rsyslog-doc-sample: validate-config
:require-plugin: omexample
.. code-block:: rsyslog
template(name="example" type="list") {
property(name="msg")
}
"""
)
result = self.run_validator()
self.assertEqual(result.returncode, 0)
self.assertIn("1 passed", result.stdout)
def test_required_plugin_accepts_builtin_module(self):
self.write_rst(
"""
.. rsyslog-doc-sample: validate-config
:require-plugin: ombuiltin
.. code-block:: rsyslog
template(name="example" type="list") {
property(name="msg")
}
"""
)
result = self.run_validator()
self.assertEqual(result.returncode, 0)
self.assertIn("1 passed", result.stdout)
def test_required_plugin_timeout_is_skipped(self):
self.write_rst(
"""
.. rsyslog-doc-sample: validate-config
:require-plugin: omhanging
.. code-block:: rsyslog
template(name="example" type="list") {
property(name="msg")
}
"""
)
result = self.run_validator("--timeout", "0.1")
self.assertEqual(result.returncode, 0)
self.assertIn("1 skipped", result.stdout)
def test_validation_failure_is_reported(self):
self.write_rst(
"""
.. rsyslog-doc-sample: validate-config
.. code-block:: rsyslog
invalid
"""
)
result = self.run_validator()
self.assertNotEqual(result.returncode, 0)
self.assertIn("FAIL sample.rst", result.stderr)
def test_validation_timeout_is_reported(self):
self.write_rst(
"""
.. rsyslog-doc-sample: validate-config
.. code-block:: rsyslog
hang
"""
)
result = self.run_validator("--timeout", "0.1")
self.assertNotEqual(result.returncode, 0)
self.assertIn("timed out", result.stderr)
log = next((self.root / "work").glob("*.log")).read_text(encoding="utf-8")
self.assertIn("timed out", log)
def test_prepend_and_append_are_written(self):
self.write_rst(
"""
.. rsyslog-doc-sample: validate-config
:prepend: global(workDirectory="/tmp")
:append: action(type="omfile" file="/dev/null")
.. code-block:: rsyslog
template(name="example" type="list") {
property(name="msg")
}
"""
)
result = self.run_validator()
self.assertEqual(result.returncode, 0)
config = next((self.root / "work").glob("*.conf")).read_text(encoding="utf-8")
self.assertIn('global(workDirectory="/tmp")', config)
self.assertIn('action(type="omfile" file="/dev/null")', config)
def test_module_search_path_includes_plugin_and_contrib_libs(self):
plugin_lib = self.build / "plugins" / "imtcp" / ".libs"
contrib_lib = self.build / "contrib" / "fmhash" / ".libs"
plugin_lib.mkdir(parents=True)
contrib_lib.mkdir(parents=True)
validator = load_validator()
search_path = validator.module_search_path(self.build).split(os.pathsep)
self.assertIn(str(self.build / "runtime" / ".libs"), search_path)
self.assertIn(str(plugin_lib), search_path)
self.assertIn(str(contrib_lib), search_path)
def test_rsyslogd_command_omits_empty_module_path(self):
empty_build = self.root / "empty-build"
cfg = self.root / "sample.conf"
cfg.write_text("", encoding="utf-8")
validator = load_validator()
cmd = validator.rsyslogd_check_command(self.rsyslogd, empty_build, cfg)
self.assertNotIn("-M", cmd)
self.assertEqual(cmd[-1], f"-f{cfg}")
if __name__ == "__main__":
unittest.main()

308
doc/tools/validate-doc-samples.py Executable file
View File

@ -0,0 +1,308 @@
#!/usr/bin/env python3
"""Validate marked rsyslog documentation config samples."""
import argparse
import dataclasses
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
MARKER_RE = re.compile(r"^(?P<indent>\s*)\.\. rsyslog-doc-sample:\s*validate-config\s*$")
OPTION_RE = re.compile(r"^\s+:(?P<key>[a-z-]+):\s*(?P<value>.*)$")
CODE_BLOCK_RE = re.compile(r"^(?P<indent>\s*)\.\. code-block::\s*(?P<language>\S+)\s*$")
DEFAULT_RSYSLOGD_TIMEOUT = 30.0
@dataclasses.dataclass
class DocSample:
source: Path
line: int
label: str
code: str
required_plugins: list[str]
prepend: list[str]
append: list[str]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Validate RST code blocks marked with '.. rsyslog-doc-sample: validate-config'."
)
parser.add_argument("--source-dir", default="doc/source", help="Documentation source tree to scan.")
parser.add_argument("--build-dir", default=".", help="Configured rsyslog build tree.")
parser.add_argument("--rsyslogd", default=None, help="Path to rsyslogd; defaults to BUILD_DIR/tools/rsyslogd.")
parser.add_argument("--work-dir", default=None, help="Directory for generated sample configs and logs.")
parser.add_argument(
"--timeout",
default=DEFAULT_RSYSLOGD_TIMEOUT,
type=float,
help="Seconds before a single rsyslogd config check times out.",
)
return parser.parse_args()
def read_metadata(lines: list[str], start: int) -> tuple[dict[str, list[str]], int]:
metadata: dict[str, list[str]] = {"require-plugin": [], "prepend": [], "append": []}
i = start
while i < len(lines):
if lines[i].strip() == "":
i += 1
continue
match = OPTION_RE.match(lines[i])
if match is None:
break
key = match.group("key")
if key not in metadata:
raise ValueError(f"unknown rsyslog-doc-sample option :{key}:")
metadata[key].append(match.group("value"))
i += 1
return metadata, i
def read_code_block(lines: list[str], start: int) -> tuple[str, int, int]:
i = start
while i < len(lines) and lines[i].strip() == "":
i += 1
if i >= len(lines):
raise ValueError("marker is not followed by a code block")
match = CODE_BLOCK_RE.match(lines[i])
if match is None:
raise ValueError("marker must be followed by '.. code-block:: rsyslog'")
language = match.group("language")
if language != "rsyslog":
raise ValueError(f"marked sample uses unsupported code-block language '{language}'")
block_indent = len(match.group("indent"))
block_line = i + 1
i += 1
while i < len(lines) and (lines[i].strip() == "" or OPTION_RE.match(lines[i])):
i += 1
code_lines: list[str] = []
content_indent = None
while i < len(lines):
line = lines[i]
if line.strip() == "":
code_lines.append("")
i += 1
continue
leading = len(line) - len(line.lstrip(" "))
if leading <= block_indent:
break
if content_indent is None:
content_indent = leading
if leading < content_indent:
break
code_lines.append(line[content_indent:])
i += 1
while code_lines and code_lines[-1] == "":
code_lines.pop()
if not code_lines:
raise ValueError("marked code block is empty")
return "\n".join(code_lines) + "\n", i, block_line
def sample_label(path: Path, line: int) -> str:
stem = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(path.with_suffix(""))).strip("-")
return f"{stem}-{line}"
def discover_samples(source_dir: Path) -> list[DocSample]:
samples: list[DocSample] = []
for path in sorted(source_dir.rglob("*.rst")):
relpath = path.relative_to(source_dir)
lines = path.read_text(encoding="utf-8").splitlines()
i = 0
while i < len(lines):
match = MARKER_RE.match(lines[i])
if match is None:
i += 1
continue
marker_line = i + 1
try:
metadata, code_start = read_metadata(lines, i + 1)
code, i, block_line = read_code_block(lines, code_start)
except ValueError as exc:
raise ValueError(f"{relpath}:{marker_line}: {exc}") from exc
samples.append(
DocSample(
source=relpath,
line=block_line,
label=sample_label(relpath, block_line),
code=code,
required_plugins=metadata["require-plugin"],
prepend=metadata["prepend"],
append=metadata["append"],
)
)
return samples
def timeout_output(exc: subprocess.TimeoutExpired) -> str:
stdout = exc.stdout or ""
stderr = exc.stderr or ""
if isinstance(stdout, bytes):
stdout = stdout.decode(errors="replace")
if isinstance(stderr, bytes):
stderr = stderr.decode(errors="replace")
return stdout + stderr
def plugin_available(build_dir: Path, plugin: str, rsyslogd: Path, timeout: float) -> bool:
candidates = [
build_dir / "plugins" / plugin / ".libs" / f"{plugin}.so",
build_dir / "plugins" / plugin / ".libs" / f"{plugin}.la",
build_dir / "plugins" / plugin / f"{plugin}.so",
build_dir / "contrib" / plugin / ".libs" / f"{plugin}.so",
build_dir / "contrib" / plugin / ".libs" / f"{plugin}.la",
build_dir / "contrib" / plugin / f"{plugin}.so",
]
if any(candidate.exists() for candidate in candidates):
return True
with tempfile.TemporaryDirectory(prefix="rsyslog-doc-sample-plugin-") as tmp:
cfg = Path(tmp) / f"{plugin}.conf"
cfg.write_text(f'module(load="{plugin}")\n', encoding="utf-8")
try:
result = subprocess.run(
rsyslogd_check_command(rsyslogd, build_dir, cfg),
cwd=build_dir,
text=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=timeout,
check=False,
)
except subprocess.TimeoutExpired:
return False
return result.returncode == 0
def module_search_path(build_dir: Path) -> str:
candidates = [
build_dir / "runtime" / ".libs",
build_dir / ".libs",
*sorted((build_dir / "plugins").glob("*/*")),
*sorted((build_dir / "contrib").glob("*/*")),
]
return os.pathsep.join(str(path) for path in candidates if path.name == ".libs" and path.is_dir())
def rsyslogd_check_command(rsyslogd: Path, build_dir: Path, cfg: Path) -> list[str]:
cmd = [str(rsyslogd), "-C", "-N1"]
module_dir = module_search_path(build_dir)
if module_dir:
cmd.append(f"-M{module_dir}")
cmd.append(f"-f{cfg}")
return cmd
def render_config(sample: DocSample) -> str:
parts: list[str] = []
if sample.prepend:
parts.extend(sample.prepend)
parts.append("")
parts.append(sample.code.rstrip("\n"))
if sample.append:
parts.append("")
parts.extend(sample.append)
return "\n".join(parts) + "\n"
def validate_sample(
sample: DocSample,
build_dir: Path,
work_dir: Path,
rsyslogd: Path,
timeout: float,
) -> str:
missing_plugins = [
plugin for plugin in sample.required_plugins if not plugin_available(build_dir, plugin, rsyslogd, timeout)
]
if missing_plugins:
print(
f"SKIP {sample.source}:{sample.line}: missing plugin(s): {', '.join(missing_plugins)}",
flush=True,
)
return "skip"
cfg = work_dir / f"{sample.label}.conf"
log = work_dir / f"{sample.label}.log"
cfg.write_text(render_config(sample), encoding="utf-8")
try:
result = subprocess.run(
rsyslogd_check_command(rsyslogd, build_dir, cfg),
cwd=build_dir,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
timeout=timeout,
check=False,
)
output = result.stdout
returncode = result.returncode
except subprocess.TimeoutExpired as exc:
output = timeout_output(exc)
output += f"\nrsyslogd config check timed out after {timeout:g} seconds.\n"
returncode = 124
log.write_text(output, encoding="utf-8")
if returncode != 0:
print(f"FAIL {sample.source}:{sample.line}: {cfg}", file=sys.stderr)
print(output, file=sys.stderr)
return "fail"
print(f"PASS {sample.source}:{sample.line}", flush=True)
return "pass"
def main() -> int:
args = parse_args()
source_dir = Path(args.source_dir).resolve()
build_dir = Path(args.build_dir).resolve()
rsyslogd = Path(args.rsyslogd).resolve() if args.rsyslogd else build_dir / "tools" / "rsyslogd"
work_dir = Path(args.work_dir).resolve() if args.work_dir else build_dir / "doc" / "build" / "doc-samples"
timeout = args.timeout
if not source_dir.is_dir():
print(f"source directory not found: {source_dir}", file=sys.stderr)
return 2
if not rsyslogd.is_file():
print(f"rsyslogd not found: {rsyslogd}", file=sys.stderr)
return 2
if timeout <= 0:
print("timeout must be greater than zero", file=sys.stderr)
return 2
shutil.rmtree(work_dir, ignore_errors=True)
work_dir.mkdir(parents=True, exist_ok=True)
try:
samples = discover_samples(source_dir)
except ValueError as exc:
print(exc, file=sys.stderr)
return 2
if not samples:
print("No marked rsyslog documentation samples found.", file=sys.stderr)
return 1
counts = {"pass": 0, "skip": 0, "fail": 0}
for sample in samples:
outcome = validate_sample(sample, build_dir, work_dir, rsyslogd, timeout)
counts[outcome] += 1
print(
"Doc sample validation summary: "
f"{counts['pass']} passed, {counts['skip']} skipped, {counts['fail']} failed."
)
return 1 if counts["fail"] else 0
if __name__ == "__main__":
sys.exit(main())