logalizer 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- logalizer/__init__.py +8 -0
- logalizer/__main__.py +4 -0
- logalizer/cli.py +284 -0
- logalizer/client.py +58 -0
- logalizer/config.py +102 -0
- logalizer/indexpatterns.py +57 -0
- logalizer/init.py +125 -0
- logalizer/ping.py +78 -0
- logalizer/reporting.py +60 -0
- logalizer/rison.py +35 -0
- logalizer-0.1.0.dist-info/METADATA +85 -0
- logalizer-0.1.0.dist-info/RECORD +16 -0
- logalizer-0.1.0.dist-info/WHEEL +5 -0
- logalizer-0.1.0.dist-info/entry_points.txt +2 -0
- logalizer-0.1.0.dist-info/licenses/LICENSE +21 -0
- logalizer-0.1.0.dist-info/top_level.txt +1 -0
logalizer/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""logalizer — export Kibana logs as CSV via the Reporting API."""
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
from importlib.metadata import version as _version
|
|
5
|
+
|
|
6
|
+
__version__ = _version("logalizer")
|
|
7
|
+
except Exception: # pragma: no cover - fallback for uninstalled source tree
|
|
8
|
+
__version__ = "0.0.0"
|
logalizer/__main__.py
ADDED
logalizer/cli.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Command-line interface for logalizer."""
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
from datetime import datetime, timedelta, timezone
|
|
8
|
+
|
|
9
|
+
from logalizer import reporting, indexpatterns
|
|
10
|
+
from logalizer import init, ping
|
|
11
|
+
from logalizer.client import Client, ClientError
|
|
12
|
+
from logalizer.config import build_settings, config_file_path, load_config
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class UsageError(Exception):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_DURATION_RE = re.compile(r"^(\d+)([smhd])$")
|
|
20
|
+
_UNIT_SECONDS = {"s": 1, "m": 60, "h": 3600, "d": 86400}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def parse_duration(text):
|
|
24
|
+
m = _DURATION_RE.match(text)
|
|
25
|
+
if not m:
|
|
26
|
+
raise ValueError(f"invalid duration: {text!r} (expected like 15m, 1h, 24h, 7d)")
|
|
27
|
+
return timedelta(seconds=int(m.group(1)) * _UNIT_SECONDS[m.group(2)])
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _iso(dt):
|
|
31
|
+
return dt.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_time_filter(from_iso, to_iso, last):
|
|
35
|
+
if from_iso and to_iso:
|
|
36
|
+
gte, lte = from_iso, to_iso
|
|
37
|
+
elif from_iso or to_iso:
|
|
38
|
+
raise UsageError("--from and --to must be used together")
|
|
39
|
+
else:
|
|
40
|
+
now = datetime.now(timezone.utc)
|
|
41
|
+
delta = parse_duration(last or "24h")
|
|
42
|
+
gte, lte = _iso(now - delta), _iso(now)
|
|
43
|
+
return {
|
|
44
|
+
"meta": {"field": "@timestamp", "params": {}},
|
|
45
|
+
"query": {"range": {"@timestamp": {
|
|
46
|
+
"format": "strict_date_optional_time",
|
|
47
|
+
"gte": gte, "lte": lte}}},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
HELP_TEXT = """\
|
|
52
|
+
logalizer — export Kibana logs as CSV (Kibana 7.17 Reporting API)
|
|
53
|
+
|
|
54
|
+
USAGE
|
|
55
|
+
logalizer [export] [OPTIONS] # export CSV (default action)
|
|
56
|
+
logalizer --list-spaces # print available spaces
|
|
57
|
+
logalizer --list-indices [--space SPACE] # print index patterns
|
|
58
|
+
logalizer --list-fields --index PATTERN # print fields (best-effort)
|
|
59
|
+
logalizer --help-json # machine-readable help
|
|
60
|
+
logalizer --init # write config.ini (wizard)
|
|
61
|
+
logalizer --init --url URL --username U --password P [--space S] [--index P] [--insecure]
|
|
62
|
+
logalizer --ping # test config + connectivity
|
|
63
|
+
|
|
64
|
+
EXAMPLES (copy-paste ready)
|
|
65
|
+
# Last 24h of errors from brain service, clean columns
|
|
66
|
+
logalizer --index 'logs-*' --query 'level:error' --last 24h \\
|
|
67
|
+
--fields '@timestamp,level,msg,logger' -o brain-errors.csv
|
|
68
|
+
|
|
69
|
+
# Absolute time window, match a correlation id (your real query shape)
|
|
70
|
+
logalizer -i 'app-logs-*' \\
|
|
71
|
+
--query '"00000000-0000-0000-0000-000000000000"' \\
|
|
72
|
+
--from 2026-08-25T10:00:00Z --to 2026-08-25T14:00:00Z
|
|
73
|
+
|
|
74
|
+
# Everything from an index, all fields, streamed to stdout for piping
|
|
75
|
+
logalizer -i 'agent-logs-*' --last 1h | grep 'status:500'
|
|
76
|
+
|
|
77
|
+
# Discover what's available
|
|
78
|
+
logalizer --list-spaces
|
|
79
|
+
logalizer --list-indices --space default
|
|
80
|
+
logalizer --list-fields --index 'logs-*'
|
|
81
|
+
|
|
82
|
+
QUERY OPTIONS
|
|
83
|
+
-q, --query KQL KQL query (default: "" = match all)
|
|
84
|
+
Examples: 'level:error'
|
|
85
|
+
'status:500 AND url:*api*'
|
|
86
|
+
'session_id:"01a03931-..."'
|
|
87
|
+
-i, --index PATTERN index pattern (REQUIRED for export), e.g. 'logs-*'
|
|
88
|
+
-s, --space SPACE Kibana space (default: from config, else default)
|
|
89
|
+
--last DURATION relative range: 30s, 15m, 1h, 24h, 7d
|
|
90
|
+
--from ISO --to ISO absolute range (ISO 8601). Overrides --last.
|
|
91
|
+
Use both, or neither.
|
|
92
|
+
--fields LIST comma-separated columns, in order.
|
|
93
|
+
Omit to export ALL fields (wide, includes internals).
|
|
94
|
+
|
|
95
|
+
OUTPUT OPTIONS
|
|
96
|
+
-o, --out PATH write CSV to file (default: stdout)
|
|
97
|
+
--timeout SECONDS max wait for the async job (default: 120)
|
|
98
|
+
--insecure skip TLS cert verification (self-signed servers)
|
|
99
|
+
-v, --verbose progress messages to stderr
|
|
100
|
+
|
|
101
|
+
DISCOVERY OPTIONS (exit 0; print to stdout, one per line)
|
|
102
|
+
--list-spaces list spaces
|
|
103
|
+
--list-indices list index patterns in --space
|
|
104
|
+
--list-fields list fields for --index (best-effort, may be partial)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
CONFIGURATION & DIAGNOSTICS
|
|
108
|
+
--init write ~/.config/logalizer/config.ini (0600).
|
|
109
|
+
Interactive wizard when url/username/password omitted;
|
|
110
|
+
non-interactive when all three are given via flags.
|
|
111
|
+
--ping health check: config -> TLS -> auth -> space -> index.
|
|
112
|
+
Prints one line per check; exit 0 all good, else first
|
|
113
|
+
failure (2 config, 3 auth, 5 network).
|
|
114
|
+
--url, --username, --password used only by --init (never for export).
|
|
115
|
+
|
|
116
|
+
CREDENTIALS (never pass on command line)
|
|
117
|
+
KIBANA_URL, KIBANA_USERNAME, KIBANA_PASSWORD # env vars (recommended)
|
|
118
|
+
or ~/.config/logalizer/config.ini # 0600 perms
|
|
119
|
+
|
|
120
|
+
I/O CONTRACT
|
|
121
|
+
stdout = CSV data (or discovery output). stderr = all logs/errors.
|
|
122
|
+
Safe to run: logalizer ... > out.csv (diagnostics never pollute CSV)
|
|
123
|
+
|
|
124
|
+
EXIT CODES
|
|
125
|
+
0 success
|
|
126
|
+
2 usage error (bad flags / missing required)
|
|
127
|
+
3 auth or permission failure (check KIBANA_USERNAME/PASSWORD, space)
|
|
128
|
+
4 job failed or timed out (see stderr for Kibana's error)
|
|
129
|
+
5 network / connection error
|
|
130
|
+
|
|
131
|
+
GOTCHAS
|
|
132
|
+
- CSV export is capped by xpack.reporting.csv.maxSizeBytes (10 MB default).
|
|
133
|
+
Very large exports will fail the job; narrow --query or --last.
|
|
134
|
+
- Omitting --fields exports EVERY field (including _id, _index, _score,
|
|
135
|
+
_type, @version, agent.*). Usually you want an explicit --fields list.
|
|
136
|
+
- --list-fields may be incomplete for read-only roles; you can still pass
|
|
137
|
+
any field name directly.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def help_json():
|
|
142
|
+
return {
|
|
143
|
+
"name": "logalizer",
|
|
144
|
+
"summary": "Export Kibana logs as CSV via the Kibana 7.17 Reporting API",
|
|
145
|
+
"flags": [
|
|
146
|
+
{"flag": "--query", "alias": "-q", "type": "string", "format": "KQL",
|
|
147
|
+
"default": "\"\"", "example": "level:error"},
|
|
148
|
+
{"flag": "--index", "alias": "-i", "type": "string", "format": "index-pattern",
|
|
149
|
+
"required": True, "example": "logs-*"},
|
|
150
|
+
{"flag": "--last", "type": "duration", "format": "30s|15m|1h|24h|7d",
|
|
151
|
+
"default": "24h", "example": "1h"},
|
|
152
|
+
{"flag": "--fields", "type": "csv-list", "default": "all fields",
|
|
153
|
+
"example": "@timestamp,level,msg"},
|
|
154
|
+
{"flag": "--out", "alias": "-o", "type": "path", "default": "stdout"},
|
|
155
|
+
{"flag": "--space", "alias": "-s", "type": "string", "default": "default"},
|
|
156
|
+
{"flag": "--init", "type": "bool", "default": "false"},
|
|
157
|
+
{"flag": "--ping", "type": "bool", "default": "false"},
|
|
158
|
+
{"flag": "--url", "type": "string", "used_by": "--init"},
|
|
159
|
+
{"flag": "--username", "type": "string", "used_by": "--init"},
|
|
160
|
+
{"flag": "--password", "type": "string", "used_by": "--init"},
|
|
161
|
+
],
|
|
162
|
+
"exit_codes": {"0": "success", "2": "usage", "3": "auth/permission",
|
|
163
|
+
"4": "job failed/timeout", "5": "network"},
|
|
164
|
+
"io_contract": {"stdout": "CSV data", "stderr": "diagnostics"},
|
|
165
|
+
"env": ["KIBANA_URL", "KIBANA_USERNAME", "KIBANA_PASSWORD"],
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def build_parser():
|
|
170
|
+
p = argparse.ArgumentParser(
|
|
171
|
+
prog="logalizer",
|
|
172
|
+
description="Export Kibana logs as CSV (Kibana 7.17 Reporting API)",
|
|
173
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
174
|
+
epilog=HELP_TEXT,
|
|
175
|
+
)
|
|
176
|
+
p.add_argument("-q", "--query", default="", help="KQL query (default: match all)")
|
|
177
|
+
p.add_argument("-i", "--index", help="index pattern, e.g. 'logs-*'")
|
|
178
|
+
p.add_argument("-s", "--space", help="Kibana space (default: from config, else default)")
|
|
179
|
+
p.add_argument("--last", default="24h", help="relative range: 30s/15m/1h/24h/7d (default 24h)")
|
|
180
|
+
p.add_argument("--from", dest="from_iso", help="absolute range start (ISO 8601)")
|
|
181
|
+
p.add_argument("--to", dest="to_iso", help="absolute range end (ISO 8601)")
|
|
182
|
+
p.add_argument("--fields", help="comma-separated columns (omit = all fields)")
|
|
183
|
+
p.add_argument("-o", "--out", help="write CSV to file (default: stdout)")
|
|
184
|
+
p.add_argument("--timeout", type=int, help="max job wait in seconds (default 120)")
|
|
185
|
+
p.add_argument("--insecure", action="store_true", help="skip TLS verification")
|
|
186
|
+
p.add_argument("-v", "--verbose", action="store_true", help="progress to stderr")
|
|
187
|
+
p.add_argument("--list-spaces", action="store_true", help="list spaces")
|
|
188
|
+
p.add_argument("--list-indices", action="store_true", help="list index patterns")
|
|
189
|
+
p.add_argument("--list-fields", action="store_true", help="list fields for --index")
|
|
190
|
+
p.add_argument("--help-json", action="store_true", help="machine-readable help")
|
|
191
|
+
p.add_argument("--init", action="store_true", help="configure and write config.ini")
|
|
192
|
+
p.add_argument("--ping", action="store_true", help="test config + connectivity")
|
|
193
|
+
p.add_argument("--url", help="Kibana URL (for --init)")
|
|
194
|
+
p.add_argument("--username", help="username (for --init)")
|
|
195
|
+
p.add_argument("--password", help="password (for --init)")
|
|
196
|
+
return p
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _err(msg, code):
|
|
200
|
+
print(f"logalizer: {msg}", file=sys.stderr)
|
|
201
|
+
return code
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def main(argv=None):
|
|
205
|
+
args = build_parser().parse_args(argv)
|
|
206
|
+
|
|
207
|
+
if args.help_json:
|
|
208
|
+
print(json.dumps(help_json(), indent=2))
|
|
209
|
+
return 0
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
cfg = load_config()
|
|
213
|
+
settings = build_settings(
|
|
214
|
+
os.environ, cfg, space=args.space, index=args.index, fields=args.fields,
|
|
215
|
+
timeout=args.timeout, insecure=(True if args.insecure else None),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
if args.init:
|
|
219
|
+
return init.run_init(args, os.environ, config_path=None)
|
|
220
|
+
|
|
221
|
+
if args.ping:
|
|
222
|
+
return ping.run_ping(settings, config_file_path())
|
|
223
|
+
|
|
224
|
+
if not settings.url or not settings.username or not settings.password:
|
|
225
|
+
return _err(
|
|
226
|
+
"missing credentials. Set KIBANA_URL/KIBANA_USERNAME/KIBANA_PASSWORD "
|
|
227
|
+
"or ~/.config/logalizer/config.ini. Exit code 2.", 2)
|
|
228
|
+
|
|
229
|
+
client = Client(settings.url, settings.username, settings.password,
|
|
230
|
+
insecure=settings.insecure)
|
|
231
|
+
|
|
232
|
+
if args.list_spaces:
|
|
233
|
+
for s in indexpatterns.list_spaces(client):
|
|
234
|
+
print(s)
|
|
235
|
+
return 0
|
|
236
|
+
if args.list_indices:
|
|
237
|
+
for t in indexpatterns.list_index_patterns(client, settings.space):
|
|
238
|
+
print(t)
|
|
239
|
+
return 0
|
|
240
|
+
if args.list_fields:
|
|
241
|
+
if not settings.index:
|
|
242
|
+
return _err("--list-fields requires --index PATTERN", 2)
|
|
243
|
+
for f in indexpatterns.list_fields(client, settings.space, settings.index):
|
|
244
|
+
print(f)
|
|
245
|
+
return 0
|
|
246
|
+
|
|
247
|
+
# export
|
|
248
|
+
if not settings.index:
|
|
249
|
+
return _err("--index is required for export", 2)
|
|
250
|
+
index_id = indexpatterns.resolve_index_pattern(
|
|
251
|
+
client, settings.space, settings.index)
|
|
252
|
+
if not index_id:
|
|
253
|
+
return _err(
|
|
254
|
+
f"index pattern {settings.index!r} not found in space "
|
|
255
|
+
f"{settings.space!r}. Use --list-indices to see available patterns.", 2)
|
|
256
|
+
|
|
257
|
+
time_filter = build_time_filter(args.from_iso, args.to_iso, args.last)
|
|
258
|
+
columns = [c.strip() for c in settings.fields.split(",") if c.strip()] if settings.fields else None
|
|
259
|
+
|
|
260
|
+
if args.verbose:
|
|
261
|
+
print(f"submitting CSV job (index={settings.index}, space={settings.space})...",
|
|
262
|
+
file=sys.stderr)
|
|
263
|
+
job_id = reporting.submit(client, settings.space, index_id,
|
|
264
|
+
args.query, time_filter, columns)
|
|
265
|
+
if args.verbose:
|
|
266
|
+
print(f"job {job_id} submitted, waiting...", file=sys.stderr)
|
|
267
|
+
reporting.poll(client, job_id, timeout=settings.timeout)
|
|
268
|
+
if args.verbose:
|
|
269
|
+
print("job completed, downloading...", file=sys.stderr)
|
|
270
|
+
csv_text = reporting.download(client, job_id)
|
|
271
|
+
|
|
272
|
+
if args.out:
|
|
273
|
+
with open(args.out, "w", encoding="utf-8") as fh:
|
|
274
|
+
fh.write(csv_text)
|
|
275
|
+
else:
|
|
276
|
+
sys.stdout.write(csv_text)
|
|
277
|
+
return 0
|
|
278
|
+
|
|
279
|
+
except ClientError as e:
|
|
280
|
+
return _err(str(e) + f" Exit code {e.exit_code}.", e.exit_code)
|
|
281
|
+
except UsageError as e:
|
|
282
|
+
return _err(str(e) + " Exit code 2.", 2)
|
|
283
|
+
except (ValueError, OSError) as e:
|
|
284
|
+
return _err(str(e) + " Exit code 2.", 2)
|
logalizer/client.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""HTTP layer: basic auth + kbn-xsrf header, urllib-backed."""
|
|
2
|
+
import base64
|
|
3
|
+
import json
|
|
4
|
+
import ssl
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.request
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ClientError(Exception):
|
|
10
|
+
def __init__(self, message, exit_code):
|
|
11
|
+
super().__init__(message)
|
|
12
|
+
self.exit_code = exit_code
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def raise_for_status(status, body, what):
|
|
16
|
+
if status == 401:
|
|
17
|
+
raise ClientError("authentication failed (401). Check KIBANA_USERNAME/KIBANA_PASSWORD.", 3)
|
|
18
|
+
if status == 403:
|
|
19
|
+
raise ClientError("permission denied (403). Role lacks access to this space.", 3)
|
|
20
|
+
if status >= 400:
|
|
21
|
+
raise ClientError(f"{what} failed ({status}): {body}", 5)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Client:
|
|
25
|
+
def __init__(self, url, username, password, insecure=False, timeout=30):
|
|
26
|
+
self.base = (url or "").rstrip("/")
|
|
27
|
+
self.auth = "Basic " + base64.b64encode(
|
|
28
|
+
f"{username}:{password}".encode("utf-8")
|
|
29
|
+
).decode("ascii")
|
|
30
|
+
self.insecure = insecure
|
|
31
|
+
self.timeout = timeout
|
|
32
|
+
|
|
33
|
+
def _context(self):
|
|
34
|
+
if self.insecure:
|
|
35
|
+
ctx = ssl.create_default_context()
|
|
36
|
+
ctx.check_hostname = False
|
|
37
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
38
|
+
return ctx
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
def request(self, method, path, body=None):
|
|
42
|
+
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
43
|
+
headers = {"Authorization": self.auth}
|
|
44
|
+
if data is not None:
|
|
45
|
+
headers["Content-Type"] = "application/json"
|
|
46
|
+
req = urllib.request.Request(
|
|
47
|
+
self.base + path, data=data, method=method, headers=headers
|
|
48
|
+
)
|
|
49
|
+
req.headers["kbn-xsrf"] = "true"
|
|
50
|
+
try:
|
|
51
|
+
with urllib.request.urlopen(req, context=self._context(),
|
|
52
|
+
timeout=self.timeout) as resp:
|
|
53
|
+
raw = resp.read().decode("utf-8")
|
|
54
|
+
return resp.status, raw
|
|
55
|
+
except urllib.error.HTTPError as e:
|
|
56
|
+
return e.code, e.read().decode("utf-8")
|
|
57
|
+
except urllib.error.URLError as e:
|
|
58
|
+
raise ClientError(f"network error: {e.reason}", 5)
|
logalizer/config.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""XDG path resolution and credential/settings assembly."""
|
|
2
|
+
import configparser
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def xdg_config_home() -> Path:
|
|
9
|
+
return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def xdg_state_home() -> Path:
|
|
13
|
+
return Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local/state"))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def xdg_cache_home() -> Path:
|
|
17
|
+
return Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def config_file_path() -> Path:
|
|
21
|
+
return xdg_config_home() / "logalizer" / "config.ini"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_config(path=None) -> dict:
|
|
25
|
+
"""Return parsed config as a nested dict; {} if missing."""
|
|
26
|
+
p = Path(path) if path else config_file_path()
|
|
27
|
+
if not p.exists():
|
|
28
|
+
return {}
|
|
29
|
+
parser = configparser.ConfigParser()
|
|
30
|
+
parser.read(str(p))
|
|
31
|
+
return {section: dict(parser.items(section)) for section in parser.sections()}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def write_config(sections, path=None):
|
|
35
|
+
"""Write a nested {section: {key: value}} dict to config.ini, chmod 0600."""
|
|
36
|
+
p = Path(path) if path else config_file_path()
|
|
37
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
parser = configparser.ConfigParser()
|
|
39
|
+
for section, items in sections.items():
|
|
40
|
+
clean = {str(k): str(v) for k, v in items.items()
|
|
41
|
+
if v is not None and v != ""}
|
|
42
|
+
if clean:
|
|
43
|
+
parser[section] = clean
|
|
44
|
+
with open(p, "w", encoding="utf-8") as fh:
|
|
45
|
+
parser.write(fh)
|
|
46
|
+
os.chmod(p, 0o600)
|
|
47
|
+
return p
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _as_bool(value, default=False) -> bool:
|
|
51
|
+
if value is None:
|
|
52
|
+
return default
|
|
53
|
+
return str(value).strip().lower() in ("1", "true", "yes", "on")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Settings:
|
|
58
|
+
url: str = ""
|
|
59
|
+
username: str = ""
|
|
60
|
+
password: str = ""
|
|
61
|
+
space: str = "default"
|
|
62
|
+
index: str = None
|
|
63
|
+
fields: str = None
|
|
64
|
+
timeout: int = 120
|
|
65
|
+
insecure: bool = False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def build_settings(env, cfg, space=None, index=None, fields=None,
|
|
69
|
+
timeout=None, insecure=None) -> Settings:
|
|
70
|
+
"""Resolve settings with precedence flag > env > config > default."""
|
|
71
|
+
kb = cfg.get("kibana", {})
|
|
72
|
+
df = cfg.get("defaults", {})
|
|
73
|
+
|
|
74
|
+
url = env.get("KIBANA_URL") or kb.get("url", "")
|
|
75
|
+
username = env.get("KIBANA_USERNAME") or kb.get("username", "")
|
|
76
|
+
password = env.get("KIBANA_PASSWORD") or kb.get("password", "")
|
|
77
|
+
|
|
78
|
+
resolved_space = space or env.get("KIBANA_SPACE") or df.get("space", "default")
|
|
79
|
+
|
|
80
|
+
resolved_timeout = timeout
|
|
81
|
+
if resolved_timeout is None and df.get("timeout"):
|
|
82
|
+
resolved_timeout = int(df["timeout"])
|
|
83
|
+
if resolved_timeout is None:
|
|
84
|
+
resolved_timeout = 120
|
|
85
|
+
|
|
86
|
+
if insecure is None:
|
|
87
|
+
env_val = env.get("KIBANA_INSECURE")
|
|
88
|
+
if env_val is not None:
|
|
89
|
+
insecure = _as_bool(env_val)
|
|
90
|
+
else:
|
|
91
|
+
insecure = _as_bool(kb.get("insecure"))
|
|
92
|
+
|
|
93
|
+
return Settings(
|
|
94
|
+
url=url,
|
|
95
|
+
username=username,
|
|
96
|
+
password=password,
|
|
97
|
+
space=resolved_space,
|
|
98
|
+
index=index if index else df.get("index"),
|
|
99
|
+
fields=fields if fields else df.get("fields"),
|
|
100
|
+
timeout=resolved_timeout,
|
|
101
|
+
insecure=insecure,
|
|
102
|
+
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Discovery helpers: spaces, index patterns, and pattern→data-view-ID resolution."""
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
from logalizer.client import raise_for_status
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def list_spaces(client):
|
|
8
|
+
status, body = client.request("GET", "/api/spaces/space")
|
|
9
|
+
raise_for_status(status, body, "list spaces")
|
|
10
|
+
return [s["id"] for s in json.loads(body)]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def list_index_patterns(client, space):
|
|
14
|
+
path = f"/s/{space}/api/saved_objects/_find?type=index-pattern&per_page=100"
|
|
15
|
+
status, body = client.request("GET", path)
|
|
16
|
+
raise_for_status(status, body, "list index patterns")
|
|
17
|
+
data = json.loads(body)
|
|
18
|
+
return [so["attributes"]["title"] for so in data.get("saved_objects", [])]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def resolve_index_pattern(client, space, pattern):
|
|
22
|
+
"""Return the data-view ID for a pattern title, or None."""
|
|
23
|
+
path = f"/s/{space}/api/saved_objects/_find?type=index-pattern&per_page=100"
|
|
24
|
+
status, body = client.request("GET", path)
|
|
25
|
+
raise_for_status(status, body, "resolve index pattern")
|
|
26
|
+
for so in json.loads(body).get("saved_objects", []):
|
|
27
|
+
if so["attributes"].get("title") == pattern:
|
|
28
|
+
return so["id"]
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def list_fields(client, space, pattern):
|
|
33
|
+
"""Best-effort field listing. Tries _fields_for_wildcard, then falls back
|
|
34
|
+
to the index-pattern's fieldAttrs. May return [] for read-only roles."""
|
|
35
|
+
path = (f"/s/{space}/api/index_patterns/_fields_for_wildcard"
|
|
36
|
+
f"?pattern={pattern}&meta_fields=_source")
|
|
37
|
+
status, body = client.request("GET", path)
|
|
38
|
+
if status == 200:
|
|
39
|
+
try:
|
|
40
|
+
fields = json.loads(body).get("fields", [])
|
|
41
|
+
if fields:
|
|
42
|
+
return [f["name"] for f in fields]
|
|
43
|
+
except (ValueError, KeyError):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
# fallback: index-pattern saved object fieldAttrs
|
|
47
|
+
path = f"/s/{space}/api/saved_objects/_find?type=index-pattern&per_page=100"
|
|
48
|
+
status, body = client.request("GET", path)
|
|
49
|
+
raise_for_status(status, body, "list fields")
|
|
50
|
+
for so in json.loads(body).get("saved_objects", []):
|
|
51
|
+
if so["attributes"].get("title") == pattern:
|
|
52
|
+
raw = so["attributes"].get("fieldAttrs", "{}")
|
|
53
|
+
try:
|
|
54
|
+
return sorted(json.loads(raw).keys())
|
|
55
|
+
except (ValueError, TypeError):
|
|
56
|
+
return []
|
|
57
|
+
return []
|
logalizer/init.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Interactive / non-interactive configuration writer."""
|
|
2
|
+
import getpass
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from logalizer import indexpatterns
|
|
7
|
+
from logalizer.client import Client, ClientError
|
|
8
|
+
from logalizer.config import _as_bool, load_config, write_config
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _prompt(label, default):
|
|
12
|
+
suffix = f" [{default}]" if default else ""
|
|
13
|
+
val = input(f"{label}{suffix}: ").strip()
|
|
14
|
+
return val if val else default
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _prompt_password(default):
|
|
18
|
+
suffix = " [set]" if default else ""
|
|
19
|
+
val = getpass.getpass(f"Password{suffix} (blank to keep): ")
|
|
20
|
+
return val if val else default
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _connect(url, username, password, insecure):
|
|
24
|
+
"""Return (client, message) on success, or (None, error) on failure."""
|
|
25
|
+
client = Client(url, username, password, insecure=insecure)
|
|
26
|
+
try:
|
|
27
|
+
status, body = client.request("GET", "/api/status")
|
|
28
|
+
except ClientError as e:
|
|
29
|
+
return None, f"connection failed: {e}"
|
|
30
|
+
version = "?"
|
|
31
|
+
if status == 200:
|
|
32
|
+
try:
|
|
33
|
+
version = json.loads(body)["version"]["number"]
|
|
34
|
+
except (ValueError, KeyError, TypeError):
|
|
35
|
+
pass
|
|
36
|
+
status, body = client.request("GET", "/internal/security/me")
|
|
37
|
+
if status in (401, 403):
|
|
38
|
+
return None, "authentication failed (wrong username/password?)"
|
|
39
|
+
if status != 200:
|
|
40
|
+
return None, f"auth endpoint returned HTTP {status}"
|
|
41
|
+
username_real = json.loads(body).get("username", "unknown")
|
|
42
|
+
return client, f"Kibana {version} as {username_real}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _pick_numbered(items, title, default):
|
|
46
|
+
"""Offer a numbered list; return selection, typed name, or default on blank.
|
|
47
|
+
If items is None (listing failed), fall back to free-text."""
|
|
48
|
+
if items:
|
|
49
|
+
print(f"{title}:")
|
|
50
|
+
for i, name in enumerate(items, 1):
|
|
51
|
+
print(f" [{i}] {name}")
|
|
52
|
+
suffix = f" [{default}]" if default else ""
|
|
53
|
+
val = input(f"Pick a number, type a name, or Enter{suffix}: ").strip()
|
|
54
|
+
if val == "":
|
|
55
|
+
return default
|
|
56
|
+
if val.isdigit() and 1 <= int(val) <= len(items):
|
|
57
|
+
return items[int(val) - 1]
|
|
58
|
+
return val
|
|
59
|
+
return _prompt(title, default)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def run_init(args, env, config_path=None):
|
|
63
|
+
existing = load_config(config_path)
|
|
64
|
+
kb = existing.get("kibana", {})
|
|
65
|
+
df = existing.get("defaults", {})
|
|
66
|
+
|
|
67
|
+
url = args.url or kb.get("url", "")
|
|
68
|
+
username = args.username or kb.get("username", "")
|
|
69
|
+
password = args.password or env.get("KIBANA_PASSWORD") or kb.get("password", "")
|
|
70
|
+
space = args.space or df.get("space", "default")
|
|
71
|
+
index = args.index or df.get("index", "")
|
|
72
|
+
insecure = args.insecure if args.insecure else _as_bool(kb.get("insecure"))
|
|
73
|
+
|
|
74
|
+
interactive = not (url and username and password)
|
|
75
|
+
|
|
76
|
+
if interactive:
|
|
77
|
+
print("Configuring logalizer (blank = keep existing)", file=sys.stderr)
|
|
78
|
+
url = _prompt("Kibana URL", url)
|
|
79
|
+
username = _prompt("Username", username)
|
|
80
|
+
password = _prompt_password(password)
|
|
81
|
+
ans = _prompt("Skip TLS verification? (y/N)", "y" if insecure else "")
|
|
82
|
+
insecure = ans.strip().lower() in ("y", "yes")
|
|
83
|
+
|
|
84
|
+
while True:
|
|
85
|
+
client, msg = _connect(url, username, password, insecure)
|
|
86
|
+
if client is not None:
|
|
87
|
+
print(f" ✓ {msg}")
|
|
88
|
+
break
|
|
89
|
+
print(f" ✗ {msg} — fix and retry")
|
|
90
|
+
url = _prompt("Kibana URL", url)
|
|
91
|
+
username = _prompt("Username", username)
|
|
92
|
+
password = _prompt_password(password)
|
|
93
|
+
ans = _prompt("Skip TLS verification? (y/N)", "y" if insecure else "")
|
|
94
|
+
insecure = ans.strip().lower() in ("y", "yes")
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
spaces = indexpatterns.list_spaces(client)
|
|
98
|
+
except ClientError:
|
|
99
|
+
spaces = None
|
|
100
|
+
space = _pick_numbered(spaces, "Available spaces", space)
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
indices = indexpatterns.list_index_patterns(client, space)
|
|
104
|
+
except ClientError:
|
|
105
|
+
indices = None
|
|
106
|
+
index = _pick_numbered(indices, f"Index patterns in '{space}'", index)
|
|
107
|
+
|
|
108
|
+
if not url or not username or not password:
|
|
109
|
+
print("logalizer: url, username and password are required.", file=sys.stderr)
|
|
110
|
+
return 2
|
|
111
|
+
|
|
112
|
+
merged = {section: dict(items) for section, items in existing.items()}
|
|
113
|
+
merged.setdefault("kibana", {}).update({
|
|
114
|
+
"url": url, "username": username, "password": password,
|
|
115
|
+
"insecure": "true" if insecure else "false",
|
|
116
|
+
})
|
|
117
|
+
merged.setdefault("defaults", {}).update({"space": space})
|
|
118
|
+
if index:
|
|
119
|
+
merged["defaults"]["index"] = index
|
|
120
|
+
else:
|
|
121
|
+
merged["defaults"].pop("index", None)
|
|
122
|
+
|
|
123
|
+
path = write_config(merged, path=config_path)
|
|
124
|
+
print(f"Wrote {path} (password: ***)")
|
|
125
|
+
return 0
|
logalizer/ping.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Config + connectivity health check."""
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
from logalizer.client import Client, ClientError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _parse_version(body):
|
|
8
|
+
try:
|
|
9
|
+
return json.loads(body)["version"]["number"]
|
|
10
|
+
except (ValueError, KeyError, TypeError):
|
|
11
|
+
return "unknown"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run_ping(settings, config_path):
|
|
15
|
+
print("logalizer ping")
|
|
16
|
+
|
|
17
|
+
if config_path.exists():
|
|
18
|
+
print(f" ✓ config: {config_path}")
|
|
19
|
+
else:
|
|
20
|
+
print(f" - no config file (using env vars) — run `logalizer --init` to persist")
|
|
21
|
+
|
|
22
|
+
if settings.url and settings.username and settings.password:
|
|
23
|
+
print(f" ✓ credentials present (user={settings.username}, password=***)")
|
|
24
|
+
else:
|
|
25
|
+
print(" ✗ missing credentials (url/username/password) — run `logalizer --init` "
|
|
26
|
+
"or set KIBANA_URL/KIBANA_USERNAME/KIBANA_PASSWORD")
|
|
27
|
+
return 2
|
|
28
|
+
|
|
29
|
+
client = Client(settings.url, settings.username, settings.password,
|
|
30
|
+
insecure=settings.insecure)
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
status, body = client.request("GET", "/api/status")
|
|
34
|
+
except ClientError as e:
|
|
35
|
+
print(f" ✗ TLS/connectivity: {e}")
|
|
36
|
+
return 5
|
|
37
|
+
version = _parse_version(body) if status == 200 else "?"
|
|
38
|
+
print(f" ✓ TLS + connectivity (Kibana {version})")
|
|
39
|
+
|
|
40
|
+
status, body = client.request("GET", "/internal/security/me")
|
|
41
|
+
if status in (401, 403):
|
|
42
|
+
print(" ✗ auth failed (401/403) — check KIBANA_USERNAME/KIBANA_PASSWORD")
|
|
43
|
+
return 3
|
|
44
|
+
if status != 200:
|
|
45
|
+
print(f" ✗ auth endpoint returned HTTP {status}")
|
|
46
|
+
return 5
|
|
47
|
+
username = json.loads(body).get("username", "unknown")
|
|
48
|
+
roles = json.loads(body).get("roles", [])
|
|
49
|
+
print(f" ✓ auth OK — logged in as {username} (roles: {', '.join(roles)})")
|
|
50
|
+
|
|
51
|
+
if not settings.space:
|
|
52
|
+
print(" - space check skipped (no space configured)")
|
|
53
|
+
else:
|
|
54
|
+
status, body = client.request("GET", "/api/spaces/space")
|
|
55
|
+
if status != 200:
|
|
56
|
+
print(f" ✗ spaces list HTTP {status}")
|
|
57
|
+
return 5
|
|
58
|
+
spaces = [s["id"] for s in json.loads(body)]
|
|
59
|
+
if settings.space in spaces:
|
|
60
|
+
print(f" ✓ space '{settings.space}' exists")
|
|
61
|
+
else:
|
|
62
|
+
avail = ", ".join(spaces) or "none"
|
|
63
|
+
print(f" ✗ space '{settings.space}' not found (available: {avail})")
|
|
64
|
+
return 2
|
|
65
|
+
|
|
66
|
+
if not settings.index:
|
|
67
|
+
print(" - index check skipped (no index configured)")
|
|
68
|
+
else:
|
|
69
|
+
from logalizer.indexpatterns import resolve_index_pattern
|
|
70
|
+
index_id = resolve_index_pattern(client, settings.space or "default", settings.index)
|
|
71
|
+
if index_id:
|
|
72
|
+
print(f" ✓ index '{settings.index}' resolves → data-view {index_id}")
|
|
73
|
+
else:
|
|
74
|
+
print(f" ✗ index '{settings.index}' not found in space '{settings.space or 'default'}'")
|
|
75
|
+
return 2
|
|
76
|
+
|
|
77
|
+
print("All checks passed.")
|
|
78
|
+
return 0
|
logalizer/reporting.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Async CSV reporting job lifecycle: submit → poll → download."""
|
|
2
|
+
import json
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from logalizer import rison
|
|
6
|
+
from logalizer.client import ClientError, raise_for_status
|
|
7
|
+
|
|
8
|
+
KIBANA_VERSION = "7.17.29"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def submit(client, space, index_id, query, time_filter, columns):
|
|
12
|
+
search_source = {
|
|
13
|
+
"index": index_id,
|
|
14
|
+
"query": {"query": query, "language": "kuery"},
|
|
15
|
+
}
|
|
16
|
+
if time_filter is not None:
|
|
17
|
+
search_source["filter"] = [time_filter]
|
|
18
|
+
job_params = {
|
|
19
|
+
"title": "logalizer export",
|
|
20
|
+
"objectType": "search",
|
|
21
|
+
"browserTimezone": "UTC",
|
|
22
|
+
"version": KIBANA_VERSION,
|
|
23
|
+
"searchSource": search_source,
|
|
24
|
+
}
|
|
25
|
+
if columns:
|
|
26
|
+
job_params["columns"] = columns
|
|
27
|
+
body = {"jobParams": rison.encode(job_params)}
|
|
28
|
+
path = f"/s/{space}/api/reporting/generate/csv_searchsource"
|
|
29
|
+
status, raw = client.request("POST", path, body)
|
|
30
|
+
raise_for_status(status, raw, "submit CSV job")
|
|
31
|
+
return json.loads(raw)["job"]["id"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def poll(client, job_id, timeout=120):
|
|
35
|
+
deadline = time.time() + timeout
|
|
36
|
+
while time.time() < deadline:
|
|
37
|
+
status, raw = client.request(
|
|
38
|
+
"GET", "/api/reporting/jobs/list?page=0&size=100"
|
|
39
|
+
)
|
|
40
|
+
raise_for_status(status, raw, "poll job")
|
|
41
|
+
for job in json.loads(raw):
|
|
42
|
+
if job["id"] == job_id:
|
|
43
|
+
if job["status"] == "completed":
|
|
44
|
+
return
|
|
45
|
+
if job["status"] == "failed":
|
|
46
|
+
out = job.get("output") or {}
|
|
47
|
+
detail = out.get("warnings") or out.get("error") or out
|
|
48
|
+
raise ClientError(
|
|
49
|
+
f"job {job_id} failed: {detail!r}. "
|
|
50
|
+
"Try narrowing --query or --last.", 4)
|
|
51
|
+
time.sleep(2)
|
|
52
|
+
raise ClientError(f"job {job_id} did not complete within {timeout}s", 4)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def download(client, job_id):
|
|
56
|
+
status, raw = client.request(
|
|
57
|
+
"GET", f"/api/reporting/jobs/download/{job_id}"
|
|
58
|
+
)
|
|
59
|
+
raise_for_status(status, raw, "download CSV")
|
|
60
|
+
return raw
|
logalizer/rison.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Minimal rison encoder (the wire format Kibana's reporting API expects).
|
|
2
|
+
|
|
3
|
+
Rison: strings '...' (! -> !!, ' -> !'), null !n, true !t, false !f,
|
|
4
|
+
array !(...), object (key:value,...). Keys matching an identifier are bare;
|
|
5
|
+
all other keys are quoted.
|
|
6
|
+
"""
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
_ID_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*$")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _escape(s):
|
|
13
|
+
return s.replace("!", "!!").replace("'", "!'")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def encode(value):
|
|
17
|
+
if value is None:
|
|
18
|
+
return "!n"
|
|
19
|
+
if value is True:
|
|
20
|
+
return "!t"
|
|
21
|
+
if value is False:
|
|
22
|
+
return "!f"
|
|
23
|
+
if isinstance(value, str):
|
|
24
|
+
return "'" + _escape(value) + "'"
|
|
25
|
+
if isinstance(value, (int, float)):
|
|
26
|
+
return str(value)
|
|
27
|
+
if isinstance(value, dict):
|
|
28
|
+
parts = []
|
|
29
|
+
for k, v in value.items():
|
|
30
|
+
key = k if _ID_RE.match(k) else "'" + _escape(k) + "'"
|
|
31
|
+
parts.append(key + ":" + encode(v))
|
|
32
|
+
return "(" + ",".join(parts) + ")"
|
|
33
|
+
if isinstance(value, (list, tuple)):
|
|
34
|
+
return "!(" + ",".join(encode(v) for v in value) + ")"
|
|
35
|
+
raise TypeError(f"cannot rison-encode {type(value).__name__}")
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: logalizer
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Export Kibana logs as CSV from the command line via the Kibana Reporting API.
|
|
5
|
+
Author: isdmx
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/isdmx/logalizer
|
|
8
|
+
Project-URL: Repository, https://github.com/isdmx/logalizer
|
|
9
|
+
Keywords: kibana,elasticsearch,csv,logs,export,cli
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Topic :: System :: Logging
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# logalizer
|
|
24
|
+
|
|
25
|
+
Export Kibana logs as CSV from the command line — via the Kibana 7.17
|
|
26
|
+
Reporting API. No third-party dependencies (pure Python stdlib).
|
|
27
|
+
|
|
28
|
+
`logalizer` authenticates to Kibana with basic auth, submits a CSV reporting
|
|
29
|
+
job, polls until it completes, and writes the result to stdout or a file. It is
|
|
30
|
+
built for both humans and scripts: clean exit codes and a strict stdout/stderr
|
|
31
|
+
separation so `logalizer ... > out.csv` always yields only CSV on stdout.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
pip install logalizer
|
|
36
|
+
|
|
37
|
+
Requires Python 3.10+.
|
|
38
|
+
|
|
39
|
+
## Configure
|
|
40
|
+
|
|
41
|
+
logalizer --init
|
|
42
|
+
|
|
43
|
+
Interactive wizard that validates your credentials and lets you pick a space and
|
|
44
|
+
index pattern from live lists. Or set environment variables:
|
|
45
|
+
|
|
46
|
+
export KIBANA_URL=https://...
|
|
47
|
+
export KIBANA_USERNAME=...
|
|
48
|
+
export KIBANA_PASSWORD=...
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
# test connectivity and configuration
|
|
53
|
+
logalizer --ping
|
|
54
|
+
|
|
55
|
+
# discover what's available
|
|
56
|
+
logalizer --list-spaces
|
|
57
|
+
logalizer --list-indices --space <space>
|
|
58
|
+
logalizer --list-fields --index '<pattern>'
|
|
59
|
+
|
|
60
|
+
# export logs as CSV
|
|
61
|
+
logalizer -i '<index-pattern>' --query 'level:error' --last 24h \
|
|
62
|
+
--fields '@timestamp,level,msg' -o errors.csv
|
|
63
|
+
|
|
64
|
+
Run `logalizer --help` for the full reference and `logalizer --help-json` for a
|
|
65
|
+
machine-readable flag schema.
|
|
66
|
+
|
|
67
|
+
## Release
|
|
68
|
+
|
|
69
|
+
Publishing is automated via GitHub Actions Trusted Publishing.
|
|
70
|
+
|
|
71
|
+
One-time setup on PyPI: project settings → Publishing → add a "pending
|
|
72
|
+
publisher" with owner `isdmx`, repository `logalizer`, workflow `publish.yml`,
|
|
73
|
+
environment `pypi`.
|
|
74
|
+
|
|
75
|
+
To cut a release, either use the GitHub Actions UI (Actions → "Release" → "Run
|
|
76
|
+
workflow", enter a version like 0.1.0) or from a local checkout:
|
|
77
|
+
|
|
78
|
+
git tag v0.1.0
|
|
79
|
+
git push --tags
|
|
80
|
+
|
|
81
|
+
Either way, CI builds the sdist + wheel and uploads them to PyPI automatically.
|
|
82
|
+
|
|
83
|
+
## License
|
|
84
|
+
|
|
85
|
+
MIT
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
logalizer/__init__.py,sha256=M_m8V_l5YNOgfQvudDFxHF3Gr3t_o3V5Xqv9Z1rJwpw,274
|
|
2
|
+
logalizer/__main__.py,sha256=RWhM-Y1tMFNj4jILR4IQEIu1w9GNkt-B0BoqHwcg2uk,88
|
|
3
|
+
logalizer/cli.py,sha256=diVnJ28oxHwzW0EJ9BB9mW1MKvWKtdYOOx7hb5Li6ks,12401
|
|
4
|
+
logalizer/client.py,sha256=zli8YTBqK6y7ua-FEphFzWQzrT1A9QpNW3g759-V9mc,2073
|
|
5
|
+
logalizer/config.py,sha256=thLnhAQzjpouTrhlSokJw3XFKrrNrfHcHbGHJG2UdDc,3102
|
|
6
|
+
logalizer/indexpatterns.py,sha256=6jObERRK8WizNHgve0W-xqKOlO0LR4uR5yDgvJSh7u4,2271
|
|
7
|
+
logalizer/init.py,sha256=AcQgy_jW2CvhukpY1DW9fB4owUYSQDEgF3dkSiLuY0o,4676
|
|
8
|
+
logalizer/ping.py,sha256=rcCAa2JV8CylPD006ygif6wGIUJVWbwKvT3e27-85CM,2879
|
|
9
|
+
logalizer/reporting.py,sha256=0qToXB40DfLd1GPNRUR2Ztg3QLNKoEkfD0RYNBPRo0Y,2036
|
|
10
|
+
logalizer/rison.py,sha256=poIgsThnBwRSEm591FB6BOipTEdcGs5Nq0n6TjXPlGI,1084
|
|
11
|
+
logalizer-0.1.0.dist-info/licenses/LICENSE,sha256=xc4IY7C5WXvCeZlnKy2FUXMnZzkBPso8lbI8pi5FAOk,1062
|
|
12
|
+
logalizer-0.1.0.dist-info/METADATA,sha256=Ot4We1NKbTK-bg0MWvKhmDclPzchFDbxMOckA6cVUoE,2661
|
|
13
|
+
logalizer-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
14
|
+
logalizer-0.1.0.dist-info/entry_points.txt,sha256=R-_mtpglMTe7FNSoc3vapDAM54LNX49BUVP8ysI8bh0,49
|
|
15
|
+
logalizer-0.1.0.dist-info/top_level.txt,sha256=3fEVfUDMmKdVV1y-n9xZyKLFNGyBof5omlzyR46p-Rk,10
|
|
16
|
+
logalizer-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 isdmx
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
logalizer
|