openobserve-cli 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.
- oo_cli/__init__.py +3 -0
- oo_cli/__main__.py +4 -0
- oo_cli/cli.py +270 -0
- oo_cli/client.py +95 -0
- oo_cli/config.py +62 -0
- oo_cli/py.typed +0 -0
- oo_cli/spec.py +129 -0
- oo_cli/timeutil.py +51 -0
- openobserve_cli-0.1.0.dist-info/METADATA +120 -0
- openobserve_cli-0.1.0.dist-info/RECORD +13 -0
- openobserve_cli-0.1.0.dist-info/WHEEL +4 -0
- openobserve_cli-0.1.0.dist-info/entry_points.txt +3 -0
- openobserve_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
oo_cli/__init__.py
ADDED
oo_cli/__main__.py
ADDED
oo_cli/cli.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Command line entry point.
|
|
2
|
+
|
|
3
|
+
Commands are a transcription of the API: `oo get dashboards` is
|
|
4
|
+
`GET /api/{org}/dashboards`. Where the instance offers a v2 of an endpoint, the
|
|
5
|
+
v2 one is used; the spec module decides that, there is no version switch.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from typing import TextIO
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from oo_cli import __version__
|
|
18
|
+
from oo_cli import spec as spec_module
|
|
19
|
+
from oo_cli.client import Client, HTTPError, OOError
|
|
20
|
+
from oo_cli.config import Config, ConfigError
|
|
21
|
+
from oo_cli.timeutil import TimeError, to_micros
|
|
22
|
+
|
|
23
|
+
VERBS = ("get", "post", "put", "patch", "delete")
|
|
24
|
+
|
|
25
|
+
EPILOG = """\
|
|
26
|
+
examples:
|
|
27
|
+
oo get dashboards
|
|
28
|
+
oo get alerts --folder default -> /api/v2/{org}/alerts?folder=default
|
|
29
|
+
oo get streams/app_logs
|
|
30
|
+
oo post alerts -f alert.json
|
|
31
|
+
oo delete dashboards/0194f0e1 --folder default
|
|
32
|
+
oo search --sql "select * from default limit 10" --from -30m
|
|
33
|
+
oo api GET /api/{org}/prometheus/api/v1/query --query "up"
|
|
34
|
+
|
|
35
|
+
environment:
|
|
36
|
+
OO_ENDPOINT base URL, default http://localhost:5080
|
|
37
|
+
OO_ORG organization, default "default"
|
|
38
|
+
OO_TOKEN base64 of "email:token", sent as HTTP basic auth
|
|
39
|
+
OO_USER alternative to OO_TOKEN, together with OO_PASSWORD
|
|
40
|
+
OO_COOKIE Cookie header for a gateway in front of OpenObserve
|
|
41
|
+
OO_TIMEOUT request timeout in seconds, default 60
|
|
42
|
+
|
|
43
|
+
Any other --name value or --name=value is passed through as a query parameter.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class UsageError(Exception):
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def main(argv: list[str] | None = None) -> int:
|
|
52
|
+
try:
|
|
53
|
+
return run(sys.argv[1:] if argv is None else argv)
|
|
54
|
+
except UsageError as exc:
|
|
55
|
+
print(f"oo: {exc}", file=sys.stderr)
|
|
56
|
+
return 2
|
|
57
|
+
except (ConfigError, TimeError) as exc:
|
|
58
|
+
print(f"oo: {exc}", file=sys.stderr)
|
|
59
|
+
return 2
|
|
60
|
+
except HTTPError as exc:
|
|
61
|
+
print(f"oo: {exc}", file=sys.stderr)
|
|
62
|
+
_emit(exc.response, raw=False, stream=sys.stderr)
|
|
63
|
+
return 1
|
|
64
|
+
except OOError as exc:
|
|
65
|
+
print(f"oo: {exc}", file=sys.stderr)
|
|
66
|
+
return 1
|
|
67
|
+
except BrokenPipeError:
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def run(argv: list[str]) -> int:
|
|
72
|
+
parser = _parser()
|
|
73
|
+
args, extras = parser.parse_known_args(_glue_offsets(argv))
|
|
74
|
+
if not args.command:
|
|
75
|
+
parser.print_help()
|
|
76
|
+
return 2
|
|
77
|
+
|
|
78
|
+
config = Config.from_env(endpoint=args.endpoint, org=args.org, timeout=args.timeout)
|
|
79
|
+
with Client(config) as client:
|
|
80
|
+
if args.command == "spec":
|
|
81
|
+
return _run_spec(client, args, extras)
|
|
82
|
+
if args.command == "search":
|
|
83
|
+
return _run_search(client, args, extras)
|
|
84
|
+
if args.command == "api":
|
|
85
|
+
return _run_api(client, args, extras)
|
|
86
|
+
return _run_verb(client, args, extras)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
#: Options whose value starts with a minus (--from -30m), which argparse reads as an option.
|
|
90
|
+
_OFFSET_OPTIONS = ("--from", "--to")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _glue_offsets(argv: list[str]) -> list[str]:
|
|
94
|
+
glued: list[str] = []
|
|
95
|
+
index = 0
|
|
96
|
+
while index < len(argv):
|
|
97
|
+
token = argv[index]
|
|
98
|
+
following = argv[index + 1] if index + 1 < len(argv) else None
|
|
99
|
+
if token in _OFFSET_OPTIONS and following is not None and following.startswith("-"):
|
|
100
|
+
glued.append(f"{token}={following}")
|
|
101
|
+
index += 2
|
|
102
|
+
continue
|
|
103
|
+
glued.append(token)
|
|
104
|
+
index += 1
|
|
105
|
+
return glued
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _parser() -> argparse.ArgumentParser:
|
|
109
|
+
parser = argparse.ArgumentParser(
|
|
110
|
+
prog="oo",
|
|
111
|
+
description="Talk to the OpenObserve API.",
|
|
112
|
+
epilog=EPILOG,
|
|
113
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
114
|
+
allow_abbrev=False,
|
|
115
|
+
)
|
|
116
|
+
parser.add_argument("--version", action="version", version=f"oo {__version__}")
|
|
117
|
+
parser.add_argument("--endpoint", help="OpenObserve base URL (env OO_ENDPOINT)")
|
|
118
|
+
parser.add_argument("--org", help="organization (env OO_ORG)")
|
|
119
|
+
parser.add_argument("--timeout", type=float, help="request timeout in seconds")
|
|
120
|
+
parser.add_argument("--raw", action="store_true", help="print the response body unformatted")
|
|
121
|
+
|
|
122
|
+
sub = parser.add_subparsers(dest="command")
|
|
123
|
+
|
|
124
|
+
for verb in VERBS:
|
|
125
|
+
p = sub.add_parser(
|
|
126
|
+
verb,
|
|
127
|
+
help=f"{verb.upper()} a resource, e.g. oo {verb} dashboards",
|
|
128
|
+
allow_abbrev=False,
|
|
129
|
+
)
|
|
130
|
+
p.add_argument(
|
|
131
|
+
"resource", help='resource path under the org, e.g. "alerts" or "alerts/<id>"'
|
|
132
|
+
)
|
|
133
|
+
_add_body_arguments(p)
|
|
134
|
+
|
|
135
|
+
p = sub.add_parser("api", help="call any path verbatim", allow_abbrev=False)
|
|
136
|
+
p.add_argument("method", help="HTTP method")
|
|
137
|
+
p.add_argument("path", help="full path, e.g. /api/default/streams")
|
|
138
|
+
_add_body_arguments(p)
|
|
139
|
+
|
|
140
|
+
p = sub.add_parser("search", help="run a SQL search", allow_abbrev=False)
|
|
141
|
+
p.add_argument("--sql", required=True, help="SQL query, e.g. select * from cloudwatch_logs")
|
|
142
|
+
p.add_argument("--from", dest="start", default="-1h", help="start time, default -1h")
|
|
143
|
+
p.add_argument("--to", dest="end", default="now", help="end time, default now")
|
|
144
|
+
p.add_argument("--size", type=int, default=100, help="number of rows, default 100")
|
|
145
|
+
p.add_argument("--offset", type=int, default=0, help="row offset, default 0")
|
|
146
|
+
p.add_argument("--type", default="logs", help="stream type: logs, metrics or traces")
|
|
147
|
+
|
|
148
|
+
p = sub.add_parser("spec", help="inspect the instance's OpenAPI document", allow_abbrev=False)
|
|
149
|
+
p.add_argument(
|
|
150
|
+
"action", choices=("paths", "refresh"), help="list endpoints or refetch the spec"
|
|
151
|
+
)
|
|
152
|
+
p.add_argument("needle", nargs="?", help="substring filter for paths")
|
|
153
|
+
|
|
154
|
+
return parser
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _add_body_arguments(parser: argparse.ArgumentParser) -> None:
|
|
158
|
+
parser.add_argument("-d", "--data", help="request body, @file or @- for stdin")
|
|
159
|
+
parser.add_argument("-f", "--file", help="request body read from a file")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _run_verb(client: Client, args: argparse.Namespace, extras: list[str]) -> int:
|
|
163
|
+
segments = [s for s in args.resource.strip("/").split("/") if s]
|
|
164
|
+
if not segments:
|
|
165
|
+
raise UsageError("a resource is required, e.g. oo get dashboards")
|
|
166
|
+
|
|
167
|
+
spec = spec_module.load(client)
|
|
168
|
+
resolution = spec.resolve(client.config.org, segments, args.command)
|
|
169
|
+
if spec and not resolution.matched:
|
|
170
|
+
print(
|
|
171
|
+
f"oo: {args.command.upper()} {resolution.path} is not in the spec, sending it anyway",
|
|
172
|
+
file=sys.stderr,
|
|
173
|
+
)
|
|
174
|
+
response = client.request(
|
|
175
|
+
args.command, resolution.path, params=_params(extras), body=_body(args)
|
|
176
|
+
)
|
|
177
|
+
_emit(response, args.raw)
|
|
178
|
+
return 0
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _run_api(client: Client, args: argparse.Namespace, extras: list[str]) -> int:
|
|
182
|
+
path = args.path if args.path.startswith("/") else "/" + args.path
|
|
183
|
+
response = client.request(args.method, path, params=_params(extras), body=_body(args))
|
|
184
|
+
_emit(response, args.raw)
|
|
185
|
+
return 0
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _run_search(client: Client, args: argparse.Namespace, extras: list[str]) -> int:
|
|
189
|
+
body = {
|
|
190
|
+
"query": {
|
|
191
|
+
"sql": args.sql,
|
|
192
|
+
"start_time": to_micros(args.start),
|
|
193
|
+
"end_time": to_micros(args.end),
|
|
194
|
+
"from": args.offset,
|
|
195
|
+
"size": args.size,
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
params = [("type", args.type), *_params(extras)]
|
|
199
|
+
response = client.request(
|
|
200
|
+
"POST", f"/api/{client.config.org}/_search", params=params, body=json.dumps(body).encode()
|
|
201
|
+
)
|
|
202
|
+
_emit(response, args.raw)
|
|
203
|
+
return 0
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _run_spec(client: Client, args: argparse.Namespace, extras: list[str]) -> int:
|
|
207
|
+
if extras:
|
|
208
|
+
raise UsageError(f"unexpected argument {extras[0]}")
|
|
209
|
+
spec = spec_module.load(client, refresh=args.action == "refresh")
|
|
210
|
+
if not spec:
|
|
211
|
+
raise OOError("could not read the OpenAPI document from the instance")
|
|
212
|
+
for path, methods in spec.paths(args.needle):
|
|
213
|
+
print(f"{' '.join(sorted(methods)).upper():<28} {path}")
|
|
214
|
+
return 0
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _params(extras: list[str]) -> list[tuple[str, str]]:
|
|
218
|
+
"""Turn leftover --name value / --name=value pairs into query parameters."""
|
|
219
|
+
params: list[tuple[str, str]] = []
|
|
220
|
+
index = 0
|
|
221
|
+
while index < len(extras):
|
|
222
|
+
token = extras[index]
|
|
223
|
+
if not token.startswith("--"):
|
|
224
|
+
raise UsageError(f"unexpected argument {token}")
|
|
225
|
+
name, _, value = token[2:].partition("=")
|
|
226
|
+
if not name:
|
|
227
|
+
raise UsageError(f"unexpected argument {token}")
|
|
228
|
+
if not _:
|
|
229
|
+
following = extras[index + 1] if index + 1 < len(extras) else None
|
|
230
|
+
if following is not None and not following.startswith("--"):
|
|
231
|
+
value = following
|
|
232
|
+
index += 1
|
|
233
|
+
else:
|
|
234
|
+
value = "true"
|
|
235
|
+
params.append((name, value))
|
|
236
|
+
index += 1
|
|
237
|
+
return params
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _body(args: argparse.Namespace) -> bytes | None:
|
|
241
|
+
data: str | None = getattr(args, "data", None)
|
|
242
|
+
file: str | None = getattr(args, "file", None)
|
|
243
|
+
if data and file:
|
|
244
|
+
raise UsageError("use either -d or -f, not both")
|
|
245
|
+
if file:
|
|
246
|
+
data = "@" + file
|
|
247
|
+
if not data:
|
|
248
|
+
return None
|
|
249
|
+
if not data.startswith("@"):
|
|
250
|
+
return data.encode()
|
|
251
|
+
source = data[1:]
|
|
252
|
+
if source == "-":
|
|
253
|
+
return sys.stdin.buffer.read()
|
|
254
|
+
try:
|
|
255
|
+
with open(source, "rb") as handle:
|
|
256
|
+
return handle.read()
|
|
257
|
+
except OSError as exc:
|
|
258
|
+
raise UsageError(f"cannot read {source}: {exc}") from exc
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _emit(response: httpx.Response, raw: bool, stream: TextIO = sys.stdout) -> None:
|
|
262
|
+
text = response.text
|
|
263
|
+
if not text.strip():
|
|
264
|
+
return
|
|
265
|
+
if not raw and "json" in response.headers.get("content-type", ""):
|
|
266
|
+
try:
|
|
267
|
+
text = json.dumps(response.json(), indent=2, ensure_ascii=False)
|
|
268
|
+
except ValueError:
|
|
269
|
+
pass
|
|
270
|
+
print(text, file=stream)
|
oo_cli/client.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Thin HTTP client for the OpenObserve API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from oo_cli.config import Config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OOError(Exception):
|
|
13
|
+
"""Anything that should end the process with a message and a non-zero code."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HTTPError(OOError):
|
|
17
|
+
def __init__(self, response: httpx.Response) -> None:
|
|
18
|
+
self.response = response
|
|
19
|
+
super().__init__(f"{response.status_code} {response.reason_phrase} {response.request.url}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Client:
|
|
23
|
+
def __init__(self, config: Config) -> None:
|
|
24
|
+
self.config = config
|
|
25
|
+
headers = {"Accept": "application/json"}
|
|
26
|
+
if config.authorization:
|
|
27
|
+
headers["Authorization"] = config.authorization
|
|
28
|
+
if config.cookie:
|
|
29
|
+
headers["Cookie"] = config.cookie
|
|
30
|
+
self._http = httpx.Client(
|
|
31
|
+
base_url=config.endpoint,
|
|
32
|
+
headers=headers,
|
|
33
|
+
timeout=config.timeout,
|
|
34
|
+
follow_redirects=False,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def __enter__(self) -> Client:
|
|
38
|
+
return self
|
|
39
|
+
|
|
40
|
+
def __exit__(self, *exc: object) -> None:
|
|
41
|
+
self._http.close()
|
|
42
|
+
|
|
43
|
+
def request(
|
|
44
|
+
self,
|
|
45
|
+
method: str,
|
|
46
|
+
path: str,
|
|
47
|
+
params: list[tuple[str, str]] | None = None,
|
|
48
|
+
body: bytes | None = None,
|
|
49
|
+
) -> httpx.Response:
|
|
50
|
+
headers = {"Content-Type": "application/json"} if body is not None else None
|
|
51
|
+
try:
|
|
52
|
+
response = self._http.request(
|
|
53
|
+
method.upper(),
|
|
54
|
+
path,
|
|
55
|
+
# A tuple, because httpx types its list of pairs invariantly.
|
|
56
|
+
params=tuple(params) if params is not None else None,
|
|
57
|
+
content=body,
|
|
58
|
+
headers=headers,
|
|
59
|
+
)
|
|
60
|
+
except httpx.ConnectError as exc:
|
|
61
|
+
raise OOError(
|
|
62
|
+
f"{self.config.endpoint} is unreachable: {exc}\n{_UNREACHABLE_HINT}"
|
|
63
|
+
) from exc
|
|
64
|
+
except httpx.HTTPError as exc:
|
|
65
|
+
raise OOError(f"request to {self.config.endpoint}{path} failed: {exc}") from exc
|
|
66
|
+
|
|
67
|
+
_reject_gateway(response)
|
|
68
|
+
if response.status_code >= 400:
|
|
69
|
+
raise HTTPError(response)
|
|
70
|
+
return response
|
|
71
|
+
|
|
72
|
+
def json(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
73
|
+
return self.request(method, path, **kwargs).json()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
_UNREACHABLE_HINT = "Set OO_ENDPOINT to a reachable OpenObserve, or start your port-forward."
|
|
77
|
+
|
|
78
|
+
_GATEWAY_HINT = (
|
|
79
|
+
"Something in front of OpenObserve is authenticating users itself and only takes its\n"
|
|
80
|
+
"own session cookie, which no API token replaces. Copy the Cookie header out of the\n"
|
|
81
|
+
"browser that is signed in (devtools, Network tab) and pass it along:\n"
|
|
82
|
+
' export OO_COOKIE="AWSELBAuthSessionCookie-0=...; AWSELBAuthSessionCookie-1=..."'
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _reject_gateway(response: httpx.Response) -> None:
|
|
87
|
+
"""Explain a redirect to an identity provider instead of leaving a parse error behind."""
|
|
88
|
+
location = response.headers.get("location", "")
|
|
89
|
+
if not response.is_redirect or not location.startswith(("http://", "https://")):
|
|
90
|
+
return
|
|
91
|
+
if httpx.URL(location).host == response.request.url.host:
|
|
92
|
+
return
|
|
93
|
+
raise OOError(
|
|
94
|
+
f"{response.request.url} redirected to {httpx.URL(location).host}\n{_GATEWAY_HINT}"
|
|
95
|
+
)
|
oo_cli/config.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Endpoint, organization and credentials, all from the environment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
DEFAULT_ENDPOINT = "http://localhost:5080"
|
|
11
|
+
DEFAULT_ORG = "default"
|
|
12
|
+
DEFAULT_TIMEOUT = 60.0
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ConfigError(Exception):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Config:
|
|
21
|
+
endpoint: str
|
|
22
|
+
org: str
|
|
23
|
+
authorization: str | None
|
|
24
|
+
cookie: str | None
|
|
25
|
+
timeout: float
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def from_env(
|
|
29
|
+
cls,
|
|
30
|
+
env: Mapping[str, str] | None = None,
|
|
31
|
+
endpoint: str | None = None,
|
|
32
|
+
org: str | None = None,
|
|
33
|
+
timeout: float | None = None,
|
|
34
|
+
) -> Config:
|
|
35
|
+
values: Mapping[str, str] = os.environ if env is None else env
|
|
36
|
+
return cls(
|
|
37
|
+
endpoint=(endpoint or values.get("OO_ENDPOINT") or DEFAULT_ENDPOINT).rstrip("/"),
|
|
38
|
+
org=org or values.get("OO_ORG") or DEFAULT_ORG,
|
|
39
|
+
authorization=_authorization(values),
|
|
40
|
+
cookie=values.get("OO_COOKIE") or None,
|
|
41
|
+
timeout=timeout or float(values.get("OO_TIMEOUT") or DEFAULT_TIMEOUT),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _authorization(env: Mapping[str, str]) -> str | None:
|
|
46
|
+
"""Build the Authorization header value.
|
|
47
|
+
|
|
48
|
+
OO_TOKEN is the credential OpenObserve hands out for a service account (base64
|
|
49
|
+
of "email:token"); it is sent as-is when it already names its scheme.
|
|
50
|
+
OO_USER + OO_PASSWORD is the same thing spelled out.
|
|
51
|
+
"""
|
|
52
|
+
token = env.get("OO_TOKEN")
|
|
53
|
+
if token:
|
|
54
|
+
token = token.strip()
|
|
55
|
+
return token if " " in token else f"Basic {token}"
|
|
56
|
+
|
|
57
|
+
user, password = env.get("OO_USER"), env.get("OO_PASSWORD")
|
|
58
|
+
if user and password:
|
|
59
|
+
return "Basic " + base64.b64encode(f"{user}:{password}".encode()).decode()
|
|
60
|
+
if user or password:
|
|
61
|
+
raise ConfigError("OO_USER and OO_PASSWORD have to be set together")
|
|
62
|
+
return None
|
oo_cli/py.typed
ADDED
|
File without changes
|
oo_cli/spec.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""The instance's own OpenAPI document, used to route a command to v1 or v2.
|
|
2
|
+
|
|
3
|
+
OpenObserve serves its spec at /api-doc/openapi.json, so the command surface follows
|
|
4
|
+
whatever version is deployed instead of a table we would have to maintain by hand.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from oo_cli.client import Client, OOError
|
|
17
|
+
|
|
18
|
+
SPEC_PATH = "/api-doc/openapi.json"
|
|
19
|
+
MAX_AGE_SECONDS = 7 * 24 * 3600
|
|
20
|
+
|
|
21
|
+
#: Resources that exist in both versions; used when the spec cannot be fetched.
|
|
22
|
+
V2_RESOURCES = frozenset({"alerts", "folders", "reports"})
|
|
23
|
+
|
|
24
|
+
_METHODS = ("get", "post", "put", "patch", "delete", "head", "options")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Resolution:
|
|
29
|
+
path: str
|
|
30
|
+
template: str | None
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def matched(self) -> bool:
|
|
34
|
+
return self.template is not None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Spec:
|
|
38
|
+
def __init__(self, templates: dict[str, list[str]]) -> None:
|
|
39
|
+
self.templates = templates
|
|
40
|
+
|
|
41
|
+
def __bool__(self) -> bool:
|
|
42
|
+
return bool(self.templates)
|
|
43
|
+
|
|
44
|
+
def resolve(self, org: str, segments: list[str], method: str) -> Resolution:
|
|
45
|
+
"""Map command segments onto a concrete API path, preferring v2.
|
|
46
|
+
|
|
47
|
+
A few endpoints (organizations, clusters) sit outside an organization, so an
|
|
48
|
+
org-less path is the last candidate.
|
|
49
|
+
"""
|
|
50
|
+
tail = "/".join(segments)
|
|
51
|
+
v2, v1, global_ = f"/api/v2/{org}/{tail}", f"/api/{org}/{tail}", f"/api/{tail}"
|
|
52
|
+
|
|
53
|
+
if not self.templates:
|
|
54
|
+
return Resolution(v2 if segments and segments[0] in V2_RESOURCES else v1, None)
|
|
55
|
+
|
|
56
|
+
for candidate in (v2, v1, global_):
|
|
57
|
+
template = self._match(candidate, method)
|
|
58
|
+
if template:
|
|
59
|
+
return Resolution(candidate, template)
|
|
60
|
+
return Resolution(v1, None)
|
|
61
|
+
|
|
62
|
+
def _match(self, path: str, method: str) -> str | None:
|
|
63
|
+
parts = path.strip("/").split("/")
|
|
64
|
+
for template, methods in self.templates.items():
|
|
65
|
+
if method.lower() not in methods:
|
|
66
|
+
continue
|
|
67
|
+
template_parts = template.strip("/").split("/")
|
|
68
|
+
if len(template_parts) != len(parts):
|
|
69
|
+
continue
|
|
70
|
+
if all(
|
|
71
|
+
t.startswith("{") and t.endswith("}") or t == p
|
|
72
|
+
for t, p in zip(template_parts, parts, strict=True)
|
|
73
|
+
):
|
|
74
|
+
return template
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
def paths(self, needle: str | None = None) -> list[tuple[str, list[str]]]:
|
|
78
|
+
items = sorted(self.templates.items())
|
|
79
|
+
if needle:
|
|
80
|
+
items = [(p, m) for p, m in items if needle.lower() in p.lower()]
|
|
81
|
+
return items
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def load(client: Client, refresh: bool = False) -> Spec:
|
|
85
|
+
"""Return the cached spec, fetching it when missing, stale or forced."""
|
|
86
|
+
cache = _cache_file(client.config.endpoint)
|
|
87
|
+
if not refresh:
|
|
88
|
+
cached = _read_cache(cache)
|
|
89
|
+
if cached is not None:
|
|
90
|
+
return Spec(cached)
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
document = client.json("GET", SPEC_PATH)
|
|
94
|
+
except OOError:
|
|
95
|
+
cached = _read_cache(cache, ignore_age=True)
|
|
96
|
+
return Spec(cached if cached is not None else {})
|
|
97
|
+
|
|
98
|
+
templates = {
|
|
99
|
+
path: [m for m in operations if m in _METHODS]
|
|
100
|
+
for path, operations in document.get("paths", {}).items()
|
|
101
|
+
}
|
|
102
|
+
_write_cache(cache, client.config.endpoint, templates)
|
|
103
|
+
return Spec(templates)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _cache_file(endpoint: str) -> Path:
|
|
107
|
+
base = os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache"
|
|
108
|
+
digest = hashlib.sha1(endpoint.encode(), usedforsecurity=False).hexdigest()[:12]
|
|
109
|
+
return Path(base) / "oo-cli" / f"spec-{digest}.json"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _read_cache(path: Path, ignore_age: bool = False) -> dict[str, list[str]] | None:
|
|
113
|
+
try:
|
|
114
|
+
payload = json.loads(path.read_text())
|
|
115
|
+
except (OSError, ValueError):
|
|
116
|
+
return None
|
|
117
|
+
if not ignore_age and time.time() - payload.get("fetched_at", 0) > MAX_AGE_SECONDS:
|
|
118
|
+
return None
|
|
119
|
+
templates = payload.get("templates")
|
|
120
|
+
return templates if isinstance(templates, dict) else None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _write_cache(path: Path, endpoint: str, templates: dict[str, list[str]]) -> None:
|
|
124
|
+
payload = {"endpoint": endpoint, "fetched_at": time.time(), "templates": templates}
|
|
125
|
+
try:
|
|
126
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
path.write_text(json.dumps(payload))
|
|
128
|
+
except OSError:
|
|
129
|
+
pass
|
oo_cli/timeutil.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Time arguments. OpenObserve takes epoch microseconds everywhere."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from datetime import UTC, datetime, timedelta
|
|
7
|
+
|
|
8
|
+
_OFFSET = re.compile(r"^([+-])(\d+)([smhdw])$")
|
|
9
|
+
_UNITS = {"s": "seconds", "m": "minutes", "h": "hours", "d": "days", "w": "weeks"}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TimeError(ValueError):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def to_micros(value: str, now: datetime | None = None) -> int:
|
|
17
|
+
"""Parse "now", an offset like -15m, epoch seconds/millis/micros, or ISO 8601.
|
|
18
|
+
|
|
19
|
+
A timestamp without a zone is read as local time.
|
|
20
|
+
"""
|
|
21
|
+
now = now or datetime.now(UTC)
|
|
22
|
+
value = value.strip()
|
|
23
|
+
|
|
24
|
+
if value == "now":
|
|
25
|
+
return int(now.timestamp() * 1_000_000)
|
|
26
|
+
|
|
27
|
+
offset = _OFFSET.match(value)
|
|
28
|
+
if offset:
|
|
29
|
+
sign, amount, unit = offset.groups()
|
|
30
|
+
delta = timedelta(**{_UNITS[unit]: int(amount)})
|
|
31
|
+
moment = now - delta if sign == "-" else now + delta
|
|
32
|
+
return int(moment.timestamp() * 1_000_000)
|
|
33
|
+
|
|
34
|
+
if value.isdigit():
|
|
35
|
+
digits = len(value)
|
|
36
|
+
if digits <= 11:
|
|
37
|
+
return int(value) * 1_000_000
|
|
38
|
+
if digits <= 14:
|
|
39
|
+
return int(value) * 1_000
|
|
40
|
+
return int(value)
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
moment = datetime.fromisoformat(value)
|
|
44
|
+
except ValueError as exc:
|
|
45
|
+
raise TimeError(
|
|
46
|
+
f"cannot read {value!r} as a time: use now, an offset (-15m, -2h, -7d), "
|
|
47
|
+
"an epoch timestamp or an ISO 8601 datetime"
|
|
48
|
+
) from exc
|
|
49
|
+
if moment.tzinfo is None:
|
|
50
|
+
moment = moment.astimezone()
|
|
51
|
+
return int(moment.timestamp() * 1_000_000)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: openobserve-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI for the OpenObserve HTTP API
|
|
5
|
+
Project-URL: Homepage, https://github.com/paveldedik/oo-cli
|
|
6
|
+
Project-URL: Repository, https://github.com/paveldedik/oo-cli
|
|
7
|
+
Project-URL: Issues, https://github.com/paveldedik/oo-cli/issues
|
|
8
|
+
Author-email: Pavel Dedík <dedikx@gmail.com>
|
|
9
|
+
Maintainer-email: Pavel Dedík <dedikx@gmail.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: cli,logs,metrics,observability,openobserve,traces
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Intended Audience :: System Administrators
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Topic :: System :: Monitoring
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Requires-Dist: httpx>=0.27
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# oo
|
|
25
|
+
|
|
26
|
+
A small CLI for the OpenObserve HTTP API. Commands are a transcription of the API rather
|
|
27
|
+
than a model of it: `oo get dashboards` is `GET /api/{org}/dashboards`, and anything the
|
|
28
|
+
CLI does not know about is still reachable with `oo api`.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
oo get dashboards
|
|
32
|
+
oo get alerts --folder default # GET /api/v2/{org}/alerts?folder=default
|
|
33
|
+
oo get streams/app_logs
|
|
34
|
+
oo post alerts -f alert.json
|
|
35
|
+
oo put dashboards/0194f0e1 -d @- < dashboard.json
|
|
36
|
+
oo delete alerts/7
|
|
37
|
+
|
|
38
|
+
oo search --sql "select * from default limit 10" --from -30m
|
|
39
|
+
oo api GET /api/default/prometheus/api/v1/query --query "up"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Any `--name value` or `--name=value` the CLI does not define becomes a query parameter.
|
|
43
|
+
`-d` takes a literal body, `@file` or `@-` for stdin; `-f file` is shorthand for `-d @file`.
|
|
44
|
+
Output is pretty-printed JSON on stdout, errors on stderr, exit code 1 on HTTP >= 400 and
|
|
45
|
+
2 on a bad command.
|
|
46
|
+
|
|
47
|
+
Times accept `now`, an offset (`-15m`, `-2h`, `-7d`), an epoch timestamp in seconds,
|
|
48
|
+
milliseconds or microseconds, or an ISO 8601 datetime (local time when it carries no zone).
|
|
49
|
+
|
|
50
|
+
## Install
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
uv tool install openobserve-cli # installs the `oo` command
|
|
54
|
+
uvx openobserve-cli get streams # or run it without installing anything
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The command is `oo`; `openobserve-cli` is the same command under the distribution's
|
|
58
|
+
name, which is what makes the `uvx` one-liner work. From a checkout: `uv sync` and
|
|
59
|
+
then `uv run oo --help`.
|
|
60
|
+
|
|
61
|
+
## Configuration
|
|
62
|
+
|
|
63
|
+
Everything comes from the environment. Nothing is required: with no variables set the
|
|
64
|
+
CLI talks to `http://localhost:5080` as an anonymous user, which is what a port-forward
|
|
65
|
+
to a local OpenObserve looks like.
|
|
66
|
+
|
|
67
|
+
| Variable | Required | Default | Meaning |
|
|
68
|
+
|---------------------------|---------------------------------|-------------------------|-----------------------------------------------|
|
|
69
|
+
| `OO_ENDPOINT` | optional | `http://localhost:5080` | base URL |
|
|
70
|
+
| `OO_ORG` | optional | `default` | organization |
|
|
71
|
+
| `OO_TOKEN` | to reach anything but `/healthz`| — | base64 of `email:token`, sent as basic auth |
|
|
72
|
+
| `OO_USER` / `OO_PASSWORD` | instead of `OO_TOKEN` | — | the same credential spelled out |
|
|
73
|
+
| `OO_COOKIE` | when a gateway guards the host | — | `Cookie` header, sent verbatim |
|
|
74
|
+
| `OO_TIMEOUT` | optional | `60` | request timeout in seconds |
|
|
75
|
+
|
|
76
|
+
`--endpoint`, `--org` and `--timeout` override the corresponding variable.
|
|
77
|
+
|
|
78
|
+
## When something authenticates in front of OpenObserve
|
|
79
|
+
|
|
80
|
+
Plenty of deployments put OpenObserve behind a gateway that authenticates users itself:
|
|
81
|
+
an AWS ALB with an `authenticate-oidc` action, oauth2-proxy, an identity-aware proxy.
|
|
82
|
+
Such a gateway takes nothing but its own session cookie, which it issues to a browser
|
|
83
|
+
at the end of an interactive login — no API token gets past it, and a CLI cannot run
|
|
84
|
+
that flow on its own.
|
|
85
|
+
|
|
86
|
+
So hand it the cookie your browser already has. In devtools, Network tab, take the
|
|
87
|
+
`Cookie` header off any request to that host (or Application, Cookies) and:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
export OO_COOKIE="AWSELBAuthSessionCookie-0=...; AWSELBAuthSessionCookie-1=..."
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
It is sent verbatim, so any gateway's cookie works, whatever it calls it. A request
|
|
94
|
+
that hits the gateway without one says so instead of failing on a page of HTML. The
|
|
95
|
+
cookie expires on the gateway's schedule — an ALB session lasts 7 days by default.
|
|
96
|
+
|
|
97
|
+
The gateway is orthogonal to OpenObserve's own authentication: the cookie gets you
|
|
98
|
+
through the door, `OO_TOKEN` still identifies you to OpenObserve.
|
|
99
|
+
|
|
100
|
+
## v1 and v2
|
|
101
|
+
|
|
102
|
+
Where an endpoint exists in both versions, v2 is used; there is no version switch. The CLI
|
|
103
|
+
reads the instance's own OpenAPI document (`/api-doc/openapi.json`) to decide, so it follows
|
|
104
|
+
whatever is deployed. The document is cached per endpoint under `~/.cache/oo-cli` for a week.
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
oo spec paths alerts # list endpoints and their methods
|
|
108
|
+
oo spec refresh # refetch after an OpenObserve upgrade
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Without a reachable spec the CLI falls back to v1, except for `alerts`, `folders` and
|
|
112
|
+
`reports`, the three resources that have a v2 in OpenObserve 0.91.
|
|
113
|
+
|
|
114
|
+
## Development
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
uv sync
|
|
118
|
+
uv run pytest
|
|
119
|
+
pre-commit install # ruff, mypy and conventional commit messages
|
|
120
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
oo_cli/__init__.py,sha256=YcD1_uucpne7GDvgTXRgAQiVsAUedeDhQzwCgKFB5K0,63
|
|
2
|
+
oo_cli/__main__.py,sha256=o8s9A9y8gskRoF0tCDFf7FnZk2DpuiIMLJywOILMcdc,85
|
|
3
|
+
oo_cli/cli.py,sha256=XkZu_9M0olJUZHfT4cqphOyozG8zDUucabLHbOS9wys,9655
|
|
4
|
+
oo_cli/client.py,sha256=mtASFEn3FpBSdPgvSWElQHjWJy8gJVi0X6u_eqNBGyo,3246
|
|
5
|
+
oo_cli/config.py,sha256=IJqEBdtOGJwkDHgKgqY7bdKTAsxpG0UD3a6Cc706HKM,1892
|
|
6
|
+
oo_cli/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
oo_cli/spec.py,sha256=ZnB1ycDF-GZzpi5Cy5nlom__ZM3oaiNvcfM72pPh3r4,4369
|
|
8
|
+
oo_cli/timeutil.py,sha256=OF3RgKioTxSk4HBv_vM6fahrtsdkbziwHqxq-AlcuFM,1541
|
|
9
|
+
openobserve_cli-0.1.0.dist-info/METADATA,sha256=W97OBNEciUPPodnDaVLixWkx2j00rAL0_fFbvXNg4Zw,5536
|
|
10
|
+
openobserve_cli-0.1.0.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
|
|
11
|
+
openobserve_cli-0.1.0.dist-info/entry_points.txt,sha256=llEuSqDhLj39jPaxdEV8T6bZ7buc_ARx7fI0AyVfKec,73
|
|
12
|
+
openobserve_cli-0.1.0.dist-info/licenses/LICENSE,sha256=sSY9MDyhGmQeG4gJx-wbK8XRLnNyE54hRoEz4jjDEwI,1072
|
|
13
|
+
openobserve_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Skip Pay s.r.o.
|
|
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.
|