diff --git a/.github/workflows/enable_log_forwarding_action_tests.yaml b/.github/workflows/enable_log_forwarding_action_tests.yaml new file mode 100644 index 00000000..d91f3e37 --- /dev/null +++ b/.github/workflows/enable_log_forwarding_action_tests.yaml @@ -0,0 +1,90 @@ +name: Enable Log Forwarding Action Tests + +on: + pull_request: + workflow_call: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + detect-changes: + runs-on: ubuntu-latest + outputs: + action: ${{ steps.filter.outputs.action }} + steps: + - uses: actions/checkout@v6 + - uses: dorny/paths-filter@v4 + id: filter + with: + filters: | + action: + - 'actions/enable-log-forwarding/**' + - '.github/workflows/enable_log_forwarding_action_tests.yaml' + + test-action: + needs: detect-changes + if: ${{ needs.detect-changes.outputs.action == 'true' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Set up uv + uses: astral-sh/setup-uv@v8.1.0 + + - name: Install tox + run: uv tool install tox --with tox-uv + + - name: Run lint, static checks, and unit tests + run: tox -e actions-lint,actions-static,actions-unit + + smoke-test-self-hosted: + needs: detect-changes + if: ${{ needs.detect-changes.outputs.action == 'true' }} + runs-on: [self-hosted-linux-amd64-noble-edge] + env: + TEST_CONFIG_FILE: 91-enable-log-forwarding-smoke-${{ github.run_id }}-${{ github.run_attempt }}.yaml + steps: + - uses: actions/checkout@v6 + + - name: Run enable log forwarding action + uses: ./actions/enable-log-forwarding + with: + files: | + /var/log/syslog + config-file-name: ${{ env.TEST_CONFIG_FILE }} + otlp-endpoint: 127.0.0.1:4317 + + - name: Verify generated config file exists + run: | + sudo test -f /etc/otelcol/config.d/${TEST_CONFIG_FILE} + + - name: Verify generated config contains expected receiver + run: | + sudo grep -q '"filelog/github_runner_optin"' /etc/otelcol/config.d/${TEST_CONFIG_FILE} + + smoke-test-github-hosted: + needs: detect-changes + if: ${{ needs.detect-changes.outputs.action == 'true' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Run enable log forwarding action on github-hosted runner + uses: ./actions/enable-log-forwarding + with: + files: | + /var/log/syslog + + - name: Verify workflow continues after github-hosted no-op + run: | + echo "enable-log-forwarding exited successfully on github-hosted runner" diff --git a/actions/enable-log-forwarding/action.yaml b/actions/enable-log-forwarding/action.yaml new file mode 100644 index 00000000..863e4429 --- /dev/null +++ b/actions/enable-log-forwarding/action.yaml @@ -0,0 +1,39 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. +name: Enable log forwarding +description: Opt in to forward selected log files from a self-hosted GitHub runner to Loki. + +inputs: + files: + description: | + Newline or comma-separated list of file paths and glob patterns to forward. + Examples: + newline-separated: /var/log/syslog\n/var/log/kern.log + comma-separated: /var/log/syslog,/var/log/kern.log + glob: /var/log/chrony/*.log + required: true + otlp-endpoint: + description: | + Optional OTLP/gRPC endpoint for upstream OpenTelemetry Collector logs export. + When not set, the action falls back to ACTION_OTEL_EXPORTER_OTLP_ENDPOINT, + which is injected by Canonical self-hosted runners. + Example: otel-gateway.internal:4317 + required: false + default: "" + config-file-name: + description: | + File name for the generated collector fragment under /etc/otelcol/config.d. + Use this to control merge/load priority relative to other fragments. + required: false + default: 90-github-runner-log-forwarding.yaml + +runs: + using: composite + steps: + - name: Configure collector for opt-in file log forwarding + shell: bash + env: + INPUT_FILES: ${{ inputs.files }} + INPUT_OTLP_ENDPOINT: ${{ inputs.otlp-endpoint }} + INPUT_CONFIG_FILE_NAME: ${{ inputs.config-file-name }} + run: python3 "${{ github.action_path }}/enable_log_forwarding.py" diff --git a/actions/enable-log-forwarding/enable_log_forwarding.py b/actions/enable-log-forwarding/enable_log_forwarding.py new file mode 100644 index 00000000..255ae9cd --- /dev/null +++ b/actions/enable-log-forwarding/enable_log_forwarding.py @@ -0,0 +1,380 @@ +#!/usr/bin/env python3 +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. +"""Configure OpenTelemetry Collector log forwarding for selected runner log files.""" + +import json +import logging +import os +import re +import shutil +import subprocess # nosec B404 +import sys +import tempfile +from pathlib import Path +from typing import Sequence + +CONFIG_DIR = "/etc/otelcol/config.d" +EXPORTER_NAME = "otlp_grpc" +SNAP_CMD = Path("/usr/bin/snap") +SUDO_CMD = Path("/usr/bin/sudo") +MKDIR_CMD = Path("/usr/bin/mkdir") +CP_CMD = Path("/usr/bin/cp") +CHMOD_CMD = Path("/usr/bin/chmod") +FILES_SPLIT_PATTERN = re.compile(r"[,\n]") # character class: split on comma or newline +SUPPORTED_CONFIG_EXTENSIONS = {".yaml", ".yml", ".json"} +# Detects the start of a top-level YAML exporters section (e.g. "exporters:"). +YAML_EXPORTERS_SECTION_PATTERN = re.compile(r"^\s*exporters\s*:\s*(?:#.*)?$") +# Detects a YAML key that matches the exporter name, optionally quoted. +YAML_EXPORTER_KEY_PATTERN_TEMPLATE = r"^\s*['\"]?{exporter_name}['\"]?\s*:\s*(?:#.*)?$" +# Detects a JSON exporters object containing the exporter key. +JSON_EXPORTER_KEY_PATTERN_TEMPLATE = ( + r'"exporters"\s*:\s*\{{[\s\S]*?"{exporter_name}"\s*:' +) + +logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") +logger = logging.getLogger(__name__) + + +def run_as_root(*args: str) -> subprocess.CompletedProcess[bytes]: + """Run a command directly as root or through sudo when available.""" + if os.geteuid() == 0: # if running as root + return subprocess.run(args, capture_output=True, check=False) # nosec B603 + if SUDO_CMD.is_file(): # if sudo is available + return subprocess.run( + [str(SUDO_CMD), *args], capture_output=True, check=False + ) # nosec B603 + logger.error("This action requires root privileges to update collector config.") + sys.exit(1) + + +def parse_files_into_list(files_input: str) -> list[str]: + """Parse comma/newline-separated file patterns into a normalized non-empty list.""" + entries = [] + for item in FILES_SPLIT_PATTERN.split(files_input): + stripped = item.strip() + if stripped: + entries.append(stripped) + + if not entries: + logger.error("Input 'files' must contain at least one path or glob.") + sys.exit(1) + + return entries + + +def resolve_endpoint() -> str: + """Resolve OTLP endpoint from explicit input, + falling back to ACTION_OTEL_EXPORTER_OTLP_ENDPOINT.""" + for env_var in ( + "INPUT_OTLP_ENDPOINT", + "ACTION_OTEL_EXPORTER_OTLP_ENDPOINT", + ): + val = os.getenv(env_var, "").strip() + if val: + return val + + logger.error( + "No OTLP endpoint was provided. Set input 'otlp-endpoint', or expose " + "ACTION_OTEL_EXPORTER_OTLP_ENDPOINT to this workflow.", + ) + sys.exit(1) + + +def is_github_hosted_runner() -> bool: + """Return whether this action is running on a GitHub-hosted runner.""" + return os.getenv("RUNNER_ENVIRONMENT", "").strip().lower() == "github-hosted" + + +def _contains_exporter_in_yaml(content: str, exporter_name: str) -> bool: + """Return whether YAML content defines exporter_name under exporters: section.""" + in_exporters = False + exporters_indent = -1 + exporter_key_pattern = re.compile( + YAML_EXPORTER_KEY_PATTERN_TEMPLATE.format( + exporter_name=re.escape(exporter_name) + ) + ) + + for line in content.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + + indent = len(line) - len(line.lstrip()) + + if in_exporters: + if indent <= exporters_indent and not stripped.startswith("-"): + in_exporters = False + elif exporter_key_pattern.match(line): + return True + + if YAML_EXPORTERS_SECTION_PATTERN.match(line): + in_exporters = True + exporters_indent = indent + + return False + + +def _contains_exporter_in_json(content: str, exporter_name: str) -> bool: + """Return whether JSON content defines exporter_name inside exporters object.""" + json_pattern = re.compile( + JSON_EXPORTER_KEY_PATTERN_TEMPLATE.format( + exporter_name=re.escape(exporter_name) + ), + ) + return bool(json_pattern.search(content)) + + +def _read_text_file(path: Path) -> str | None: + """Read file content safely, returning None on read errors.""" + try: + return path.read_text(encoding="utf-8", errors="replace") + except OSError: + return None + + +def _is_supported_config_file(path: Path) -> bool: + """Return whether path is a supported config file extension.""" + return path.is_file() and path.suffix.lower() in SUPPORTED_CONFIG_EXTENSIONS + + +def exporter_exists_in_config_dir( + exporter_name: str, config_dir: str, exclude_path: str +) -> bool: + """Check if an exporter with the given name is already defined in another config fragment.""" + config_dir_path = Path(config_dir) + exclude = Path(exclude_path).resolve() + if not config_dir_path.is_dir(): + return False + + for config_file in config_dir_path.iterdir(): + if not _is_supported_config_file(config_file): + continue + if config_file.resolve() == exclude: + continue + + content = _read_text_file(config_file) + if content is None: + continue + + if config_file.suffix.lower() in { + ".yaml", + ".yml", + } and _contains_exporter_in_yaml(content, exporter_name): + return True + if config_file.suffix.lower() == ".json" and _contains_exporter_in_json( + content, exporter_name + ): + return True + + return False + + +def build_resource_attributes() -> list[dict[str, str]]: + """Build static GitHub resource attributes attached to forwarded logs.""" + attrs = [ + ("github.repository", os.getenv("GITHUB_REPOSITORY", "unknown")), + ("github.runner", os.getenv("RUNNER_NAME", "unknown")), + ("github.workflow", os.getenv("GITHUB_WORKFLOW", "unknown")), + ("github.job", os.getenv("GITHUB_JOB", "unknown")), + ("github.run.id", os.getenv("GITHUB_RUN_ID", "unknown")), + ("github.run.attempt", os.getenv("GITHUB_RUN_ATTEMPT", "unknown")), + ] + return [{"key": key, "value": value, "action": "upsert"} for key, value in attrs] + + +def build_config( + files: Sequence[str], + resolved_endpoint: str, + exporter_name: str, + define_exporter: bool = True, +) -> str: + """Build a collector pipeline config fragment for opt-in log forwarding.""" + config: dict = { + "receivers": { + "filelog/github_runner_optin": { + "include": list(files), + "start_at": "end", + } + }, + "processors": { + "resource/github_runner_optin": { + "attributes": build_resource_attributes(), + } + }, + "service": { + "pipelines": { + "logs/github_runner_optin": { + "receivers": ["filelog/github_runner_optin"], + "processors": ["resource/github_runner_optin", "batch"], + "exporters": [exporter_name], + } + } + }, + } + if define_exporter: + config["exporters"] = { + exporter_name: { + "endpoint": resolved_endpoint, + } + } + + return json.dumps(config, indent=2) + + +def read_files_input() -> str: + """Read and validate the required files input.""" + files_input = os.getenv("INPUT_FILES", "").strip() + if not files_input: + logger.error("Input 'files' cannot be empty.") + sys.exit(1) + return files_input + + +def read_config_file_name() -> str: + """Read and validate the destination config file name.""" + config_file_name = os.getenv( + "INPUT_CONFIG_FILE_NAME", "90-github-runner-log-forwarding.yaml" + ).strip() + if ( + not config_file_name + or config_file_name in {".", ".."} + or Path(config_file_name).name != config_file_name + ): + logger.error( + "Input 'config-file-name' must be a non-empty file name without directory components.", + ) + sys.exit(1) + + return config_file_name + + +def ensure_collector_is_available() -> None: + """Ensure the opentelemetry-collector snap is available on the runner.""" + if not SNAP_CMD.is_file(): + logger.error("Required command is missing: snap") + sys.exit(1) + + snap_list_result = subprocess.run( # nosec B603 + [str(SNAP_CMD), "list", "opentelemetry-collector"], + capture_output=True, + check=False, + ) + if snap_list_result.returncode == 0: + return + + logger.info("opentelemetry-collector is not installed; attempting installation.") + install_result = run_as_root(str(SNAP_CMD), "install", "opentelemetry-collector") + if install_result.returncode != 0: + stderr = install_result.stderr.decode(errors="replace").strip() + logger.error( + "Failed to install opentelemetry-collector snap: %s", + stderr or "unknown error", + ) + sys.exit(1) + logger.info("Installed opentelemetry-collector snap.") + + +def write_collector_config(config_content: str, config_path: str) -> None: + """Write generated config to /etc/otelcol/config.d via root privileges.""" + config_path_obj = Path(config_path) + with tempfile.NamedTemporaryFile( + mode="w", suffix=".yaml", delete=False, encoding="utf-8" + ) as tmp: + tmp.write(config_content) + tmp_path = Path(tmp.name) + + try: + if os.geteuid() == 0: + config_path_obj.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(tmp_path, config_path_obj) + config_path_obj.chmod(0o644) + else: + mkdir_result = run_as_root(str(MKDIR_CMD), "-p", CONFIG_DIR) + if mkdir_result.returncode != 0: + stderr = mkdir_result.stderr.decode(errors="replace").strip() + logger.error( + "Failed to create collector config directory '%s': %s", + CONFIG_DIR, + stderr or "unknown error", + ) + sys.exit(1) + + copy_result = run_as_root(str(CP_CMD), str(tmp_path), str(config_path_obj)) + if copy_result.returncode != 0: + stderr = copy_result.stderr.decode(errors="replace").strip() + logger.error( + "Failed to copy collector config to '%s': %s", + config_path, + stderr or "unknown error", + ) + sys.exit(1) + + chmod_result = run_as_root(str(CHMOD_CMD), "0644", str(config_path_obj)) + if chmod_result.returncode != 0: + stderr = chmod_result.stderr.decode(errors="replace").strip() + logger.error( + "Failed to set collector config permissions on '%s': %s", + config_path, + stderr or "unknown error", + ) + sys.exit(1) + finally: + tmp_path.unlink(missing_ok=True) + + logger.info("Wrote log-forwarding collector config to: %s", config_path) + + +def log_generated_config(config_content: str) -> None: + """Emit generated config content in grouped GitHub Actions logs.""" + print("::group::Generated collector config") + print(config_content) + print("::endgroup::") + + +def restart_collector() -> None: + """Restart collector service so new config is loaded.""" + if not SNAP_CMD.is_file(): + logger.error("Required command is missing: snap") + sys.exit(1) + + restart_result = run_as_root(str(SNAP_CMD), "restart", "opentelemetry-collector") + if restart_result.returncode != 0: + stderr = restart_result.stderr.decode(errors="replace").strip() + logger.error( + "Failed to restart opentelemetry-collector: %s", + stderr or "unknown error", + ) + sys.exit(1) + logger.info("Restarted opentelemetry-collector to apply log-forwarding config.") + + +def main(): + """Validate inputs, write collector config, and restart the collector service.""" + if is_github_hosted_runner(): + logger.info("GitHub-hosted runner detected; skipping collector configuration.") + return + + files_input = read_files_input() + config_file_name = read_config_file_name() + config_path = str(Path(CONFIG_DIR) / config_file_name) + ensure_collector_is_available() + + files = parse_files_into_list(files_input) + + resolved_endpoint = resolve_endpoint() + define_exporter = not exporter_exists_in_config_dir( + EXPORTER_NAME, CONFIG_DIR, config_path + ) + + config_content = build_config( + files, resolved_endpoint, EXPORTER_NAME, define_exporter + ) + log_generated_config(config_content) + write_collector_config(config_content, config_path) + restart_collector() + + +if __name__ == "__main__": + main() diff --git a/actions/enable-log-forwarding/tests/test_enable_log_forwarding.py b/actions/enable-log-forwarding/tests/test_enable_log_forwarding.py new file mode 100644 index 00000000..358b522a --- /dev/null +++ b/actions/enable-log-forwarding/tests/test_enable_log_forwarding.py @@ -0,0 +1,254 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. +"""Unit tests for the enable_log_forwarding action script.""" + +import importlib +import json +import pathlib +import sys +import tempfile +from typing import Any + +import pytest + +ACTION_DIR = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ACTION_DIR)) + +module: Any = importlib.import_module("enable_log_forwarding") + + +@pytest.mark.parametrize( + ("files_input", "expected"), + [ + ( + " /var/log/a.log,\n/var/log/b.log ,, /var/log/c*.log\n", + ["/var/log/a.log", "/var/log/b.log", "/var/log/c*.log"], + ), + ( + "\n/var/log/only.log\n", + ["/var/log/only.log"], + ), + ], +) +def test_parse_files_into_list(files_input: str, expected: list[str]): + """ + arrange: provide comma/newline-separated files input variants. + act: parse the files input into a normalized list. + assert: output keeps only non-empty, trimmed file paths. + """ + # Arrange + + # Act + parsed = module.parse_files_into_list(files_input) + + # Assert + assert parsed == expected + + +@pytest.mark.parametrize( + ("input_endpoint", "fallback_endpoint", "expected_endpoint"), + [ + ("input-endpoint:4318", "system-endpoint:4318", "input-endpoint:4318"), + ("", "system-endpoint:4318", "system-endpoint:4318"), + ], +) +def test_resolve_endpoint_uses_input_then_fallback( + monkeypatch, input_endpoint: str, fallback_endpoint: str, expected_endpoint: str +): + """ + arrange: set explicit input and fallback endpoint environment variables. + act: resolve the endpoint used by the action. + assert: resolver returns explicit input first, otherwise fallback endpoint. + """ + # Arrange + monkeypatch.setenv("INPUT_OTLP_ENDPOINT", input_endpoint) + monkeypatch.setenv("ACTION_OTEL_EXPORTER_OTLP_ENDPOINT", fallback_endpoint) + + # Act + resolved = module.resolve_endpoint() + + # Assert + assert resolved == expected_endpoint + + +@pytest.mark.parametrize( + ("runner_environment", "expected"), + [ + ("github-hosted", True), + ("self-hosted", False), + ], +) +def test_is_github_hosted_runner_detection( + monkeypatch, runner_environment: str, expected: bool +): + """ + arrange: set RUNNER_ENVIRONMENT for different runner types. + act: check whether runner is github-hosted. + assert: returns expected boolean per runner environment. + """ + # Arrange + monkeypatch.setenv("RUNNER_ENVIRONMENT", runner_environment) + + # Act + is_github_hosted = module.is_github_hosted_runner() + + # Assert + assert is_github_hosted is expected + + +@pytest.mark.parametrize("runner_environment", ["github-hosted", "GITHUB-HOSTED"]) +def test_main_skips_configuration_on_github_hosted( + monkeypatch, runner_environment: str +): + """ + arrange: set github-hosted environment variants and guard setup functions. + act: run main. + assert: setup functions are not called and action exits successfully. + """ + # Arrange + monkeypatch.setenv("RUNNER_ENVIRONMENT", runner_environment) + + def _should_not_be_called() -> str: + raise AssertionError("read_files_input should not be called") + + monkeypatch.setattr(module, "read_files_input", _should_not_be_called) + + # Act + module.main() + + +@pytest.mark.parametrize( + ("define_exporter", "has_exporters_block"), + [ + (True, True), + (False, False), + ], +) +def test_build_config_exporter_block_is_conditionally_defined( + monkeypatch, define_exporter: bool, has_exporters_block: bool +): + """ + arrange: define GitHub metadata env vars. + act: build and parse collector config for selected log files. + assert: exporter block is included only when define_exporter is True. + """ + # Arrange + monkeypatch.setenv("GITHUB_REPOSITORY", "canonical/github-runner-operators") + monkeypatch.setenv("RUNNER_NAME", "runner-1") + monkeypatch.setenv("GITHUB_WORKFLOW", "CI") + monkeypatch.setenv("GITHUB_JOB", "test") + monkeypatch.setenv("GITHUB_RUN_ID", "123") + monkeypatch.setenv("GITHUB_RUN_ATTEMPT", "1") + + # Act + raw = module.build_config( + ["/var/log/syslog"], + "otel:4318", + module.EXPORTER_NAME, + define_exporter=define_exporter, + ) + config = json.loads(raw) + + # Assert + assert config["receivers"]["filelog/github_runner_optin"]["include"] == [ + "/var/log/syslog" + ] + assert config["service"]["pipelines"]["logs/github_runner_optin"]["exporters"] == [ + module.EXPORTER_NAME + ] + assert ("exporters" in config) is has_exporters_block + if has_exporters_block: + assert config["exporters"][module.EXPORTER_NAME]["endpoint"] == "otel:4318" + + +@pytest.mark.parametrize( + ("file_name", "content", "expected"), + [ + ( + "91-other.yaml", + f"exporters:\n {module.EXPORTER_NAME}:\n endpoint: otel:4317\n", + True, + ), + ( + "91-other.json", + "{\n" + ' "exporters": {\n' + f' "{module.EXPORTER_NAME}": {{\n' + ' "endpoint": "otel:4317"\n' + " }\n" + " }\n" + "}\n", + True, + ), + ( + "91-other.yaml", + f"receivers:\n {module.EXPORTER_NAME}:\n endpoint: otel:4317\n", + False, + ), + ], +) +def test_exporter_exists_in_config_dir_detects_supported_formats_and_sections( + file_name: str, content: str, expected: bool +): + """ + arrange: prepare config fragments in different formats and sections. + act: check whether exporter exists in the config directory. + assert: returns True only when exporter is defined under exporters in supported formats. + """ + # Arrange + with tempfile.TemporaryDirectory() as config_dir: + existing = pathlib.Path(config_dir) / file_name + existing.write_text(content) + exclude = str(pathlib.Path(config_dir) / "91-optin.logs.yaml") + + # Act + found = module.exporter_exists_in_config_dir( + module.EXPORTER_NAME, config_dir, exclude + ) + + # Assert + assert found is expected + + +def test_exporter_exists_in_config_dir_ignores_exclude_path(): + """ + arrange: write a YAML config file with the exporter, but mark it as the excluded path. + act: check whether the exporter exists excluding that file. + assert: returns False since the only matching file is excluded. + """ + # Arrange + with tempfile.TemporaryDirectory() as config_dir: + target = pathlib.Path(config_dir) / "90-github-runner-log-forwarding.yaml" + target.write_text( + f"exporters:\n {module.EXPORTER_NAME}:\n endpoint: otel:4317\n" + ) + + # Act + found = module.exporter_exists_in_config_dir( + module.EXPORTER_NAME, config_dir, str(target) + ) + + # Assert + assert found is False + + +def test_resolve_endpoint_exits_when_no_endpoint_set(monkeypatch): + """ + arrange: ensure both endpoint environment variables are unset. + act: resolve the endpoint. + assert: exits with status code 1 when no endpoint is available. + """ + # Arrange + monkeypatch.delenv("INPUT_OTLP_ENDPOINT", raising=False) + monkeypatch.delenv("ACTION_OTEL_EXPORTER_OTLP_ENDPOINT", raising=False) + + # Act + try: + module.resolve_endpoint() + except SystemExit as error: + exit_code = error.code + else: + exit_code = None + + # Assert + assert exit_code == 1 diff --git a/docs/changelog.md b/docs/changelog.md index ae0a4b20..fd7dd68c 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Each revision is versioned by the date of the revision. +## 2026-04-22 + +- add action to allow workflow authors to opt in to forwarding specific log files from self-hosted GitHub runners to Loki through the OpenTelemetry Collector snap. + ## 2026-04-13 - add 5xx error logging to planner routes. diff --git a/docs/conf.py b/docs/conf.py index 9dbfeaf0..274bf1a3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -272,6 +272,7 @@ extensions = [ "canonical_sphinx", + "myst_parser", "notfound.extension", "sphinx_design", "sphinx_reredirects", @@ -293,6 +294,11 @@ "sphinx_sitemap", ] +source_suffix = { + ".rst": "restructuredtext", + ".md": "markdown", +} + # Excludes files or directories from processing exclude_patterns = [ diff --git a/docs/how-to/enable-log-forwarding.md b/docs/how-to/enable-log-forwarding.md new file mode 100644 index 00000000..d8549a09 --- /dev/null +++ b/docs/how-to/enable-log-forwarding.md @@ -0,0 +1,66 @@ +# How to enable log forwarding + +The `enable-log-forwarding` action allows workflow authors to opt in to forwarding specific log files from self-hosted GitHub runners to Loki through the OpenTelemetry Collector snap. + +By default, nothing is forwarded. Log forwarding starts only when this action is used in a workflow. + +## Prerequisites + +- Use a self-hosted Linux runner. +- Ensure the workflow can update `/etc/otelcol/config.d` with root privileges. + +When this action runs on a GitHub-hosted runner, it performs a no-op and exits successfully so the job can continue. + +The action installs `opentelemetry-collector` when it is missing. + +## Provide inputs + +To enable log forwarding, set the following inputs in your workflow file as required by your setup: + +- `files` (required): newline or comma separated file paths or glob patterns. +- `config-file-name` (optional, default `90-github-runner-log-forwarding.yaml`): generated config file name. +- `otlp-endpoint` (optional): OTLP/gRPC endpoint used to create the exporter when one is not already configured. + +Avoid adding files that can contain secrets or sensitive information, because forwarded log lines are exported to your telemetry backend. + +When `otlp-endpoint` is not set, the action falls back to `ACTION_OTEL_EXPORTER_OTLP_ENDPOINT` from the workflow environment. + +## Use the action + +Add this snippet to a job in your workflow file (for example, `.github/workflows/ci.yaml`): + +```yaml +jobs: + chrony-testing: + runs-on: [self-hosted, linux] + steps: + - uses: canonical/github-runner-operators/actions/enable-log-forwarding@main + with: + files: | + /var/log/chrony/*.log + /var/log/syslog +``` + +Pin the action to a release tag or commit SHA in production workflows. + +Use these checks to confirm forwarding: + +- Check the action step is completed and prints success messages in the workflow logs. +- Generate new log lines after the action step and query Loki to confirm they arrive. + +## Examine Loki queries + +The action adds GitHub context as resource attributes on forwarded logs: + +- `github.job` +- `github.repository` +- `github.runner` +- `github.workflow` +- `github.run.id` +- `github.run.attempt` + +Example Loki query by workflow run id: + +``` +{github_run_id="123456789"} +``` diff --git a/docs/how-to/index.rst b/docs/how-to/index.rst index 47992476..27cca196 100644 --- a/docs/how-to/index.rst +++ b/docs/how-to/index.rst @@ -7,3 +7,4 @@ The following guides cover key processes and common tasks for managing and using :maxdepth: 1 Contribute + Enable log forwarding diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..a9f0bf76 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,13 @@ +[tool.mypy] +python_version = "3.12" +ignore_missing_imports = true + +[tool.pylint.main] +jobs = 1 + +[tool.bandit] +exclude_dirs = [ + "actions/enable-log-forwarding/tests", + "charms", + "docs", +] diff --git a/tox.ini b/tox.ini index 7808cf7b..c8e7294b 100644 --- a/tox.ini +++ b/tox.ini @@ -4,11 +4,12 @@ [tox] no_package = True skip_missing_interpreters = True -env_list = format, lint, static +env_list = format, lint, static, actions-lint, actions-static, actions-unit min_version = 4.0.0 [vars] tests_path = {tox_root}/charms/tests +actions_path = {tox_root}/actions [testenv] setenv = @@ -69,3 +70,31 @@ commands = --log-cli-level=INFO \ {[vars]tests_path}/integration \ {posargs} + +[testenv:actions-lint] +description = Run formatting and lint checks for Python code under actions/ +deps = + black + ruff +commands = + black --check {[vars]actions_path} + ruff check {[vars]actions_path} + +[testenv:actions-static] +description = Run static analysis for Python code under actions/ +deps = + mypy + pylint + bandit + pytest +commands = + mypy {[vars]actions_path} + pylint {[vars]actions_path} + bandit -q -r {[vars]actions_path} -x {[vars]actions_path}/enable-log-forwarding/tests + +[testenv:actions-unit] +description = Run unit tests for Python code under actions/ +deps = + pytest +commands = + pytest -v -s {[vars]actions_path}