Skip to content

Commit a2b37a9

Browse files
author
Ahmed Hedi
committed
cmk-crash: add CLI for batch-uploading crash reports and corresponding tests
Add cmk/crash_reporting/cli.py providing the cmk-upload-crashes console-script entry point: reads the automatic-upload global settings (defense-in-depth gate on automatic_crash_report_upload / crash_report_contact_email) and drives the existing batch-upload library. Jira: CMK-36387 Change-Id: Ic395e6564f6562e645907dded77f030bbd6d6828
1 parent cd9a44a commit a2b37a9

4 files changed

Lines changed: 224 additions & 2 deletions

File tree

module_layers.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -664,7 +664,7 @@ allows = ["@ccc", "@trace"]
664664
allows = ["@ccc"]
665665

666666
[components."cmk.crash_reporting"]
667-
allows = ["cmk.crash"]
667+
allows = ["cmk.ccc.site", "cmk.ccc.store", "cmk.ccc.version", "cmk.crash"]
668668

669669
[components."cmk.crypto"]
670670
allows = ["@ccc"]

packages/cmk-crash/BUILD

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,17 @@ py_library(
3434
srcs = [
3535
"cmk/crash_reporting/__init__.py",
3636
"cmk/crash_reporting/_packaging.py",
37+
"cmk/crash_reporting/cli.py",
3738
"cmk/crash_reporting/upload.py",
3839
],
3940
data = [":crash_reporting_py_typed"],
4041
imports = ["."],
4142
visibility = ["//visibility:public"],
4243
deps = [
4344
":crash",
45+
"//packages/cmk-ccc:site",
46+
"//packages/cmk-ccc:store",
47+
"//packages/cmk-ccc:version",
4448
requirement("requests"),
4549
],
4650
)
@@ -98,7 +102,6 @@ py_wheel(
98102
# This wheel is only used for providing the entry point "cmk-upload-crashes" from the .venv under
99103
# .venv/bin. We explicitly need to *not* add dependencies here because venv/IDE would take the
100104
# static wheels and not the editable sources from the repo which are included via sitecustomize.py.
101-
# The CLI implementation lands in Epic 2 (CMK-202); the entry point is wired here as a placeholder.
102105
py_wheel(
103106
name = "wheel_entrypoint_only",
104107
distribution = "cmk-crash-entrypoint",
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
#!/usr/bin/env python3
2+
# Copyright (C) 2026 Checkmk GmbH - License: GNU General Public License v2
3+
# This file is part of Checkmk (https://checkmk.com). It is subject to the terms and
4+
# conditions defined in the file COPYING, which is part of this source code package.
5+
"""CLI entrypoint `cmk-upload-crashes`: batch-upload pending crash reports."""
6+
7+
from __future__ import annotations
8+
9+
import os
10+
import sys
11+
from argparse import ArgumentParser
12+
from collections.abc import Sequence
13+
from dataclasses import dataclass
14+
from logging import DEBUG, Formatter, getLogger, INFO, StreamHandler
15+
from pathlib import Path
16+
17+
from cmk.ccc.site import omd_site
18+
from cmk.ccc.store import load_mk_file
19+
from cmk.ccc.version import edition
20+
from cmk.crash import make_crash_report_base_path
21+
22+
from .upload import run_batch
23+
24+
logger = getLogger("cmk.crash_reporting.cli")
25+
26+
# Matches ConfigVariableCrashReportURL's default (cmk/gui/general_config.py).
27+
_DEFAULT_CRASH_REPORT_URL = "https://crash.checkmk.com"
28+
29+
30+
@dataclass(slots=True)
31+
class Arguments:
32+
dry_run: bool = False
33+
verbose: int = 0
34+
35+
36+
def parse_arguments(argv: Sequence[str]) -> Arguments:
37+
p = ArgumentParser(description=__doc__)
38+
p.add_argument(
39+
"--dry-run", action="store_true", help="Log what would be uploaded, upload nothing"
40+
)
41+
p.add_argument(
42+
"-v",
43+
"--verbose",
44+
action="count",
45+
default=0,
46+
help="Verbose mode (use multiple times for more output)",
47+
)
48+
return p.parse_args(argv, namespace=Arguments())
49+
50+
51+
def setup_logging(*, verbose: int) -> None:
52+
getLogger("cmk").setLevel(INFO if verbose == 0 else DEBUG)
53+
handler = StreamHandler(sys.stderr)
54+
handler.setFormatter(Formatter("%(message)s"))
55+
getLogger().addHandler(handler)
56+
57+
58+
def main() -> int:
59+
arguments = parse_arguments(sys.argv[1:])
60+
setup_logging(verbose=arguments.verbose)
61+
62+
# Read OMD_ROOT directly instead of depending on cmk.utils.paths: this CLI
63+
# only ever needs the root path itself, and paths.py is one flat module
64+
# with no way to depend on just that one name.
65+
try:
66+
omd_root = Path(os.environ["OMD_ROOT"])
67+
except KeyError as exc:
68+
raise RuntimeError(
69+
"OMD_ROOT environment variable not set. Can only be executed in a Checkmk site."
70+
) from exc
71+
72+
settings = load_mk_file(
73+
omd_root / "etc/check_mk/multisite.d/wato/global.mk", default={}, lock=False
74+
)
75+
mail = str(settings.get("crash_report_contact_email", ""))
76+
if not settings.get("automatic_crash_report_upload", False) or not mail:
77+
logger.info("Automatic crash report upload is disabled or unconfigured - nothing to do.")
78+
return 0
79+
80+
run_batch(
81+
crash_report_url=str(settings.get("crash_report_url", _DEFAULT_CRASH_REPORT_URL)),
82+
base_path=make_crash_report_base_path(omd_root),
83+
# Identify the sending site by edition + site name, rather than by a
84+
# user alias as the manual GUI submit does.
85+
name=f"{edition(omd_root).short} {omd_site()}",
86+
mail=mail,
87+
dry_run=arguments.dry_run,
88+
)
89+
return 0
90+
91+
92+
if __name__ == "__main__":
93+
sys.exit(main())
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
#!/usr/bin/env python3
2+
# Copyright (C) 2026 Checkmk GmbH - License: GNU General Public License v2
3+
# This file is part of Checkmk (https://checkmk.com). It is subject to the terms and
4+
# conditions defined in the file COPYING, which is part of this source code package.
5+
"""Unit tests for cmk.crash_reporting.cli.
6+
7+
Network calls are intercepted with `responses`; no real OMD site is needed.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from pathlib import Path
13+
14+
import pytest
15+
import responses
16+
17+
from cmk.ccc.site import omd_site
18+
from cmk.crash_reporting import cli
19+
20+
_CRASH_URL = "https://crash.checkmk.com"
21+
22+
23+
def _write_global_mk(path: Path, **settings: object) -> None:
24+
path.parent.mkdir(parents=True, exist_ok=True)
25+
path.write_text("\n".join(f"{key} = {value!r}" for key, value in settings.items()))
26+
27+
28+
@pytest.fixture(autouse=True)
29+
def _fake_site(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
30+
monkeypatch.setenv("OMD_ROOT", str(tmp_path))
31+
monkeypatch.setenv("OMD_SITE", "mysite")
32+
monkeypatch.setattr("sys.argv", ["cmk-upload-crashes"])
33+
omd_site.cache_clear()
34+
35+
36+
def _global_mk_path(tmp_path: Path) -> Path:
37+
return tmp_path / "etc/check_mk/multisite.d/wato/global.mk"
38+
39+
40+
@pytest.mark.parametrize(
41+
"settings",
42+
[
43+
pytest.param(None, id="no-settings-file"),
44+
pytest.param({"automatic_crash_report_upload": True}, id="enabled-but-no-email"),
45+
],
46+
)
47+
def test_gate_blocks_upload_is_silent_noop(
48+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, settings: dict[str, object] | None
49+
) -> None:
50+
if settings is not None:
51+
_write_global_mk(_global_mk_path(tmp_path), **settings)
52+
called = False
53+
54+
def _fake_run_batch(**_kwargs: object) -> None:
55+
nonlocal called
56+
called = True
57+
58+
monkeypatch.setattr(cli, "run_batch", _fake_run_batch)
59+
assert cli.main() == 0
60+
assert not called
61+
62+
63+
def test_toggle_on_with_email_calls_run_batch(
64+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
65+
) -> None:
66+
_write_global_mk(
67+
_global_mk_path(tmp_path),
68+
automatic_crash_report_upload=True,
69+
crash_report_contact_email="admin@example.com",
70+
)
71+
captured: dict[str, object] = {}
72+
73+
def _fake_run_batch(**kwargs: object) -> None:
74+
captured.update(kwargs)
75+
76+
monkeypatch.setattr(cli, "run_batch", _fake_run_batch)
77+
assert cli.main() == 0
78+
assert captured["mail"] == "admin@example.com"
79+
assert captured["name"] == "community mysite"
80+
assert captured["crash_report_url"] == _CRASH_URL
81+
assert captured["dry_run"] is False
82+
83+
84+
def test_crash_report_url_override_is_honored(
85+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
86+
) -> None:
87+
_write_global_mk(
88+
_global_mk_path(tmp_path),
89+
automatic_crash_report_upload=True,
90+
crash_report_contact_email="admin@example.com",
91+
crash_report_url="https://crash.example.com",
92+
)
93+
captured: dict[str, object] = {}
94+
monkeypatch.setattr(cli, "run_batch", lambda **kwargs: captured.update(kwargs))
95+
cli.main()
96+
assert captured["crash_report_url"] == "https://crash.example.com"
97+
98+
99+
def test_dry_run_flag_passed_through(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
100+
_write_global_mk(
101+
_global_mk_path(tmp_path),
102+
automatic_crash_report_upload=True,
103+
crash_report_contact_email="admin@example.com",
104+
)
105+
captured: dict[str, object] = {}
106+
monkeypatch.setattr(cli, "run_batch", lambda **kwargs: captured.update(kwargs))
107+
monkeypatch.setattr("sys.argv", ["cmk-upload-crashes", "--dry-run"])
108+
cli.main()
109+
assert captured["dry_run"] is True
110+
111+
112+
@responses.activate
113+
def test_end_to_end_uploads_via_real_run_batch(tmp_path: Path) -> None:
114+
_write_global_mk(
115+
_global_mk_path(tmp_path),
116+
automatic_crash_report_upload=True,
117+
crash_report_contact_email="admin@example.com",
118+
)
119+
crash_dir = tmp_path / "var/check_mk/crashes/check/11111111-1111-1111-1111-111111111111"
120+
crash_dir.mkdir(parents=True)
121+
(crash_dir / "crash.info").write_bytes(b'{"id": "11111111-1111-1111-1111-111111111111"}')
122+
responses.add(responses.POST, _CRASH_URL, body=b"OK abc123", status=200)
123+
124+
assert cli.main() == 0
125+
assert len(responses.calls) == 1
126+
assert (crash_dir / ".uploaded").exists()

0 commit comments

Comments
 (0)