mirror of
https://github.com/rsyslog/rsyslog.git
synced 2026-08-19 18:37:45 +02:00
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:
parent
7a4cd1fb50
commit
8fe4a208d1
58
.github/workflows/doc_build.yml
vendored
58
.github/workflows/doc_build.yml
vendored
@ -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' ||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 \
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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(
|
||||
|
||||
272
doc/tools/test_validate_doc_samples.py
Executable file
272
doc/tools/test_validate_doc_samples.py
Executable 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
308
doc/tools/validate-doc-samples.py
Executable 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())
|
||||
Loading…
x
Reference in New Issue
Block a user