nandi/atop-analyzepublic Fork 0
main
Commits
Clone
git clone https://git.rickub.com/nandi/atop-analyze.git
git clone ssh://git@rickub.com/nandi/atop-analyze.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

modal_atop_analyze.py · 95 lines · 3.3 KBPython Blame HistoryRaw
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
"""Modal ingestion app for atop history stored on the local machine.

Run from the machine that has ``/var/log/atop``:

    modal run modal_atop_analyze.py --date 20260921 --initialize

The local entrypoint only reads the binary atop log. Parsing and the ClickHouse
write run in Modal; the ClickHouse endpoint and Proxy Token live in the
``atop-clickhouse-proxy`` Modal Secret, never in local shell environment.
"""

from __future__ import annotations

import os
import logging

import modal

from atop_analyze import AtopError, log_path, parse, run_atop, write_clickhouse

# Modal captures container stderr. Configure it explicitly because the runtime
# does not install an INFO-level handler for application loggers by default.
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(name)s %(message)s",
    force=True,
)
app = modal.App("atop-analyze")
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
# ``modal run`` mounts this entrypoint automatically, but imports are not
# automatically included in the remote container. Bake the shared parser into
# the function image so the remote ingest function can import it.
image = modal.Image.debian_slim(python_version="3.12").add_local_file(
    "atop_analyze.py", "/root/atop_analyze.py", copy=True
)
clickhouse_secret = modal.Secret.from_name(
    "atop-clickhouse-proxy",
    required_keys=["CLICKHOUSE_URL", "MODAL_KEY", "MODAL_SECRET"],
)


@app.function(image=image, secrets=[clickhouse_secret], timeout=15 * 60)
def ingest(raw_atop: str, initialize: bool = False) -> dict[str, int]:
    """Parse an atop export and persist its samples through private ClickHouse."""
    logger.info("ingest_started raw_bytes=%d initialize=%s", len(raw_atop.encode()), initialize)
    samples = parse(raw_atop)
    if not samples:
        raise RuntimeError("no usable atop samples in the supplied log range")
    process_rows = sum(len(sample.procs) for sample in samples)
    logger.info(
        "atop_parsed samples=%d process_rows=%d first_epoch=%d last_epoch=%d",
        len(samples), process_rows, samples[0].epoch, samples[-1].epoch,
    )

    write_clickhouse(
        samples,
        os.environ["CLICKHOUSE_URL"],
        os.environ.get("CLICKHOUSE_DATABASE", "default"),
        os.environ.get("CLICKHOUSE_SAMPLE_TABLE", "atop_samples"),
        os.environ.get("CLICKHOUSE_PROCESS_TABLE", "atop_processes"),
        None,
        None,
        os.environ["MODAL_KEY"],
        os.environ["MODAL_SECRET"],
        initialize,
    )
    logger.info("clickhouse_write_complete samples=%d process_rows=%d", len(samples), process_rows)
    return {
        "samples": len(samples),
        "process_rows": process_rows,
    }


@app.local_entrypoint()
def export(
    date: str | None = None,
    file: str | None = None,
    logdir: str = "/var/log/atop",
    begin: str | None = None,
    end: str | None = None,
    initialize: bool = False,
) -> None:
    """Read the local host's atop log, then delegate ingestion to Modal."""
    try:
        path = file or log_path(date, logdir)
        raw_atop = run_atop(path, begin, end)
    except AtopError as error:
        raise RuntimeError(f"could not read local atop history: {error}") from error

    result = ingest.remote(raw_atop, initialize)
    print(
        f"exported {result['samples']} samples and {result['process_rows']} "
        f"process rows from {path}"
    )