toolforge-logs-cli 0.0.3__tar.gz
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.
- toolforge_logs_cli-0.0.3/PKG-INFO +16 -0
- toolforge_logs_cli-0.0.3/pyproject.toml +46 -0
- toolforge_logs_cli-0.0.3/toolforge_logs_cli/__init__.py +0 -0
- toolforge_logs_cli-0.0.3/toolforge_logs_cli/cli.py +176 -0
- toolforge_logs_cli-0.0.3/toolforge_logs_cli/config.py +23 -0
- toolforge_logs_cli-0.0.3/toolforge_logs_cli/logs.py +183 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: toolforge-logs-cli
|
|
3
|
+
Version: 0.0.3
|
|
4
|
+
Summary: Toolforge logs API client
|
|
5
|
+
License: GPLv3
|
|
6
|
+
Author: David Caro
|
|
7
|
+
Author-email: dcaro@wikimedia.org
|
|
8
|
+
Requires-Python: >=3.13
|
|
9
|
+
Classifier: License :: Other/Proprietary License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
14
|
+
Requires-Dist: click (>=8.5.0,<9.0.0)
|
|
15
|
+
Requires-Dist: requests (>=2.34.2,<3.0.0)
|
|
16
|
+
Requires-Dist: toolforge-weld (>=1.6.15,<2.0.0)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "toolforge-logs-cli"
|
|
3
|
+
version = "0.0.3"
|
|
4
|
+
description = "Toolforge logs API client"
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "David Caro",email = "dcaro@wikimedia.org"}
|
|
7
|
+
]
|
|
8
|
+
license = {text = "GPLv3"}
|
|
9
|
+
requires-python = ">=3.13"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"click (>=8.5.0,<9.0.0)",
|
|
12
|
+
"requests (>=2.34.2,<3.0.0)",
|
|
13
|
+
"toolforge-weld (>=1.6.15,<2.0.0)"
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[tool.poetry.plugins."console_scripts"]
|
|
17
|
+
"toolforge-logs" = "toolforge_logs_cli.cli:main"
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
21
|
+
build-backend = "poetry.core.masonry.api"
|
|
22
|
+
|
|
23
|
+
[dependency-groups]
|
|
24
|
+
dev = [
|
|
25
|
+
"pytest (>=9.1.1,<10.0.0)",
|
|
26
|
+
"pre-commit (>=4.6.2,<5.0.0)",
|
|
27
|
+
"ruff (>=0.16.7,<0.17.0)",
|
|
28
|
+
"mypy (>=2.3.1,<3.0.0)",
|
|
29
|
+
"pytest-cov (>=7.1.0,<8.0.0)"
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[tool.coverage.run]
|
|
33
|
+
include = ["*/toolforge_components_cli/*"]
|
|
34
|
+
|
|
35
|
+
[tool.ruff.lint]
|
|
36
|
+
# add isort
|
|
37
|
+
extend-select = ["I"]
|
|
38
|
+
|
|
39
|
+
[tool.mypy]
|
|
40
|
+
exclude = [
|
|
41
|
+
"^debian/", # debian packaging files
|
|
42
|
+
"^.cache",
|
|
43
|
+
"^build",
|
|
44
|
+
"^dist",
|
|
45
|
+
"^.tox",
|
|
46
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import urlencode, urljoin
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
from toolforge_weld.errors import (
|
|
12
|
+
ToolforgeError,
|
|
13
|
+
ToolforgeUserError,
|
|
14
|
+
print_error_context,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from toolforge_logs_cli.config import get_loaded_config
|
|
18
|
+
from toolforge_logs_cli.logs import (
|
|
19
|
+
LogsClient,
|
|
20
|
+
LogsClientError,
|
|
21
|
+
UnknownLogFormat,
|
|
22
|
+
log_to_string,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
LOGGER = logging.getLogger("toolforge" if __name__ == "__main__" else __name__)
|
|
26
|
+
|
|
27
|
+
ERROR_REPORT_MSG = "Please report this issue to the Toolforge admins if it persists: https://w.wiki/6Zuu"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def handle_error(e: Exception, debug: bool = False) -> None:
|
|
31
|
+
user_error = isinstance(e, ToolforgeUserError)
|
|
32
|
+
prefix = "Error: " if user_error else f"{e.__class__.__name__}: "
|
|
33
|
+
click.echo(click.style(f"{prefix}{e}", fg="red"), err=True)
|
|
34
|
+
|
|
35
|
+
if debug:
|
|
36
|
+
LOGGER.exception(e)
|
|
37
|
+
if isinstance(e, ToolforgeError):
|
|
38
|
+
print_error_context(e)
|
|
39
|
+
elif not user_error:
|
|
40
|
+
click.echo(click.style(ERROR_REPORT_MSG, fg="red"), err=True)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _should_prompt() -> bool:
|
|
44
|
+
return sys.stdin.isatty()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@click.version_option(prog_name="Toolforge Logs CLI")
|
|
48
|
+
@click.group(name="toolforge-logs", help="Toolforge Logs command line interface")
|
|
49
|
+
@click.option(
|
|
50
|
+
"-v",
|
|
51
|
+
"--verbose",
|
|
52
|
+
help="Show extra verbose output. NOTE: Do not rely on the format of the verbose output.",
|
|
53
|
+
is_flag=True,
|
|
54
|
+
default=(os.environ.get("TOOLFORGE_VERBOSE", "0") == "1"),
|
|
55
|
+
hidden=(os.environ.get("TOOLFORGE_CLI", "0") == "1"),
|
|
56
|
+
)
|
|
57
|
+
@click.option(
|
|
58
|
+
"-d",
|
|
59
|
+
"--debug",
|
|
60
|
+
help="Show logs to debug the toolforge-logs-* packages. For extra verbose output see --verbose.",
|
|
61
|
+
is_flag=True,
|
|
62
|
+
default=(os.environ.get("TOOLFORGE_DEBUG", "0") == "1"),
|
|
63
|
+
hidden=(os.environ.get("TOOLFORGE_CLI", "0") == "1"),
|
|
64
|
+
)
|
|
65
|
+
@click.pass_context
|
|
66
|
+
def toolforge_logs(ctx, verbose: bool, debug: bool) -> None:
|
|
67
|
+
ctx.ensure_object(dict)
|
|
68
|
+
ctx.obj.update(
|
|
69
|
+
{
|
|
70
|
+
"verbose": verbose,
|
|
71
|
+
"debug": debug,
|
|
72
|
+
"config": get_loaded_config(),
|
|
73
|
+
"logs_api": LogsClient.from_config(config=get_loaded_config()),
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@toolforge_logs.command(name="get", help="Get tool logs.")
|
|
79
|
+
@click.option(
|
|
80
|
+
"--json",
|
|
81
|
+
"as_json",
|
|
82
|
+
is_flag=True,
|
|
83
|
+
help="If set, will output in JSON format",
|
|
84
|
+
)
|
|
85
|
+
@click.pass_context
|
|
86
|
+
def get_logs(ctx, as_json: bool) -> None:
|
|
87
|
+
logs_api: LogsClient = ctx.obj["logs_api"]
|
|
88
|
+
|
|
89
|
+
for log in _get_logs_nofollow(logs_api=logs_api):
|
|
90
|
+
if not as_json:
|
|
91
|
+
try:
|
|
92
|
+
log_string = log_to_string(log=log)
|
|
93
|
+
print(f"{log_string}")
|
|
94
|
+
except UnknownLogFormat as error:
|
|
95
|
+
click.echo(
|
|
96
|
+
message=click.style(f"Unable to parse log: {error}", fg="red"),
|
|
97
|
+
err=True,
|
|
98
|
+
)
|
|
99
|
+
else:
|
|
100
|
+
print(json.dumps(log))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _get_logs_page(
|
|
104
|
+
logs_api: LogsClient,
|
|
105
|
+
url: str,
|
|
106
|
+
):
|
|
107
|
+
logs = []
|
|
108
|
+
# We’re not using the params kwarg of api.get to avoid overwriting pagination URL params from replies.
|
|
109
|
+
# Pagination URLs already include all necessary params, set by the server based on what we sent first.
|
|
110
|
+
response = logs_api.get(url, timeout=None)
|
|
111
|
+
try:
|
|
112
|
+
entries = response["data"]["logs"]
|
|
113
|
+
logs.extend(entries)
|
|
114
|
+
except KeyError:
|
|
115
|
+
raise LogsClientError("unexpected response format", {"response": response})
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
next_url = response["data"]["next"]
|
|
119
|
+
except KeyError:
|
|
120
|
+
next_url = None # no more pages
|
|
121
|
+
try:
|
|
122
|
+
cursor_url = next_url["url"] # ty: ignore[not-subscriptable]
|
|
123
|
+
except (KeyError, TypeError): # TypeError raised by None["url"]
|
|
124
|
+
# logs-api’s OpenAPI spec technically allows next=null
|
|
125
|
+
if next_url is not None:
|
|
126
|
+
raise LogsClientError("unexpected cursor format", {"cursor": next_url})
|
|
127
|
+
next_url = None
|
|
128
|
+
if not entries and next_url is not None:
|
|
129
|
+
raise LogsClientError(
|
|
130
|
+
"bad server: got a pagination cursor but no logs",
|
|
131
|
+
{"response": response},
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if next_url is not None:
|
|
135
|
+
try:
|
|
136
|
+
next_url = urljoin(url, cursor_url)
|
|
137
|
+
except Exception: # noqa: BLE001
|
|
138
|
+
raise LogsClientError("bad server: invalid cursor URL", {"url": next_url})
|
|
139
|
+
if next_url == url:
|
|
140
|
+
raise LogsClientError(
|
|
141
|
+
"bad server: identical pagination cursor",
|
|
142
|
+
{"cursor": cursor_url, "url": url},
|
|
143
|
+
)
|
|
144
|
+
return next_url, logs
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _get_logs_nofollow(logs_api: LogsClient) -> Iterator[dict[str, Any]]:
|
|
148
|
+
# logs-api returns log entries in pages of bounded size (500 entries at the time of writing).
|
|
149
|
+
# We get all the logs, not filtering yet.
|
|
150
|
+
url = "/logs?" + urlencode({"direction": "oldest_first"})
|
|
151
|
+
while url:
|
|
152
|
+
url, log_batch = _get_logs_page(logs_api=logs_api, url=url)
|
|
153
|
+
yield from log_batch
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def main() -> int:
|
|
157
|
+
debug = False
|
|
158
|
+
try:
|
|
159
|
+
args = sys.argv[1:]
|
|
160
|
+
debug = "-d" in args or "--debug" in args
|
|
161
|
+
logging.basicConfig(level=logging.DEBUG if debug else logging.INFO)
|
|
162
|
+
toolforge_logs(standalone_mode=False)
|
|
163
|
+
return 0
|
|
164
|
+
except click.exceptions.ClickException as e:
|
|
165
|
+
e.show()
|
|
166
|
+
return e.exit_code
|
|
167
|
+
except subprocess.CalledProcessError as e:
|
|
168
|
+
handle_error(e, debug=debug)
|
|
169
|
+
return e.returncode
|
|
170
|
+
except Exception as e: # noqa: BLE001
|
|
171
|
+
handle_error(e, debug=debug)
|
|
172
|
+
return 1
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
if __name__ == "__main__":
|
|
176
|
+
sys.exit(main())
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from functools import cache
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from toolforge_weld.config import Config, Section, load_config
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class LogsConfig(Section):
|
|
10
|
+
_NAME_: str = field(default="logs", init=False)
|
|
11
|
+
logs_endpoint: str = "/logs/v1"
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
def from_dict(cls, my_dict: dict[str, Any]):
|
|
15
|
+
params = {}
|
|
16
|
+
if "logs_endpoint" in my_dict:
|
|
17
|
+
params["logs_endpoint"] = my_dict["logs_endpoint"]
|
|
18
|
+
return cls(**params)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@cache
|
|
22
|
+
def get_loaded_config() -> Config:
|
|
23
|
+
return load_config(client_name="logs", extra_sections=[LogsConfig])
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import socket
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
from toolforge_weld.api_client import ConnectionError, ToolforgeClient
|
|
6
|
+
from toolforge_weld.errors import ToolforgeError, ToolforgeUserError
|
|
7
|
+
from toolforge_weld.kubernetes_config import Kubeconfig
|
|
8
|
+
|
|
9
|
+
from toolforge_logs_cli.config import Config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LogsClientError(ToolforgeError):
|
|
13
|
+
"""Raised when an HTTP request fails."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, message: str, context: dict[str, Any] | None = None) -> None:
|
|
16
|
+
super().__init__(message)
|
|
17
|
+
|
|
18
|
+
if context:
|
|
19
|
+
# property is defined in parent class
|
|
20
|
+
self.context = context
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LogsClientUserError(LogsClientError, ToolforgeUserError):
|
|
24
|
+
"""Raised when an HTTP request fails with a 4xx status code."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def handle_http_exception(e: requests.exceptions.HTTPError) -> Exception:
|
|
28
|
+
if e.response is None:
|
|
29
|
+
return LogsClientError(message="Got no response", context={})
|
|
30
|
+
|
|
31
|
+
error_class: type[LogsClientError | LogsClientUserError]
|
|
32
|
+
if 400 <= e.response.status_code <= 499:
|
|
33
|
+
error_class = LogsClientUserError
|
|
34
|
+
else:
|
|
35
|
+
error_class = LogsClientError
|
|
36
|
+
|
|
37
|
+
context = {}
|
|
38
|
+
message = e.response.text
|
|
39
|
+
try:
|
|
40
|
+
data = e.response.json()
|
|
41
|
+
if isinstance(data, dict):
|
|
42
|
+
if "messages" in data and "error" in data["messages"]:
|
|
43
|
+
message = data["messages"]["error"][0]
|
|
44
|
+
context = {"messages": data["messages"]}
|
|
45
|
+
elif isinstance(data, str):
|
|
46
|
+
message = data
|
|
47
|
+
except requests.exceptions.InvalidJSONError:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
return error_class(message=message, context=context)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def handle_connection_error(e: ConnectionError) -> Exception:
|
|
54
|
+
context = {}
|
|
55
|
+
if isinstance(e, requests.exceptions.HTTPError) and e.response is not None:
|
|
56
|
+
context["body"] = e.response.text
|
|
57
|
+
|
|
58
|
+
return LogsClientError(
|
|
59
|
+
message="The logs service seems to be down – please retry in a few minutes.",
|
|
60
|
+
context=context,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class LogsClient(ToolforgeClient):
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
kubeconfig: Kubeconfig,
|
|
68
|
+
server: str,
|
|
69
|
+
endpoint_prefix: str,
|
|
70
|
+
user_agent: str,
|
|
71
|
+
):
|
|
72
|
+
super().__init__(
|
|
73
|
+
kubeconfig=kubeconfig,
|
|
74
|
+
server=server + endpoint_prefix,
|
|
75
|
+
user_agent=user_agent,
|
|
76
|
+
exception_handler=handle_http_exception,
|
|
77
|
+
connect_exception_handler=handle_connection_error,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def from_config(cls, config: Config):
|
|
82
|
+
host = socket.gethostname()
|
|
83
|
+
kubeconfig = Kubeconfig.load()
|
|
84
|
+
namespace = kubeconfig.current_namespace
|
|
85
|
+
user_agent = f"{namespace}@{host}:logs-cli"
|
|
86
|
+
tool_name = namespace[len("tool-") :]
|
|
87
|
+
full_endpoint_prefix = f"{config.logs.logs_endpoint}/tool/{tool_name}"
|
|
88
|
+
return cls(
|
|
89
|
+
endpoint_prefix=full_endpoint_prefix,
|
|
90
|
+
kubeconfig=kubeconfig,
|
|
91
|
+
server=config.api_gateway.url,
|
|
92
|
+
user_agent=user_agent,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class LogError(Exception):
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class UnknownLogFormat(LogError):
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def log_to_string(log: dict[str, Any]) -> str:
|
|
105
|
+
if "source" not in log:
|
|
106
|
+
raise UnknownLogFormat(f"No `source` found in log event: {log}")
|
|
107
|
+
|
|
108
|
+
source = log["source"]
|
|
109
|
+
|
|
110
|
+
match source:
|
|
111
|
+
case "run-logs":
|
|
112
|
+
return _format_run_log(log=log)
|
|
113
|
+
case "jobs":
|
|
114
|
+
return _format_job_event(event=log)
|
|
115
|
+
case "deployments":
|
|
116
|
+
return _format_deployment_event(event=log)
|
|
117
|
+
case _:
|
|
118
|
+
raise UnknownLogFormat(f"Unknown source {source} for log: {log}")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _format_run_log(log: dict[str, Any]) -> str:
|
|
122
|
+
try:
|
|
123
|
+
return f"{log['datetime']} [{log['job_name']}] [{log['pod']}|{log['container']}] {log['message']}"
|
|
124
|
+
except KeyError as error:
|
|
125
|
+
return f"ERROR: unable to parse run-log, missing property {error}\n original log:\n{log}"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _format_deployment_event(event: dict[str, Any]) -> str:
|
|
129
|
+
if "event_type" not in event:
|
|
130
|
+
raise UnknownLogFormat(f"No `event_type` found in deployment event: {event}")
|
|
131
|
+
|
|
132
|
+
event_type = event["event_type"]
|
|
133
|
+
match event_type:
|
|
134
|
+
case "created":
|
|
135
|
+
return (
|
|
136
|
+
f"{event['datetime']} "
|
|
137
|
+
f"DEPLOYMENT CREATED: deployment '{event['deployment_id']}' ({event['description'] or 'no description'}) "
|
|
138
|
+
f"created by user '{event['user_name']}', "
|
|
139
|
+
f"affected builds are {event['builds']} and affected jobs are {event['jobs']}"
|
|
140
|
+
)
|
|
141
|
+
case "failed":
|
|
142
|
+
return (
|
|
143
|
+
f"{event['datetime']} "
|
|
144
|
+
f"DEPLOYMENT FAILED: deployment '{event['deployment_id']}' failed ({event['description'] or 'no description'}), "
|
|
145
|
+
f"affected builds are {event['builds']} and affected jobs are {event['jobs']}. Message (format: {event['message_format']}): '{event['message']}'"
|
|
146
|
+
)
|
|
147
|
+
case "succeeded":
|
|
148
|
+
return (
|
|
149
|
+
f"{event['datetime']} "
|
|
150
|
+
f"DEPLOYMENT SUCCEEDED: deployment '{event['deployment_id']}' succeeded ({event['description'] or 'no description'}), "
|
|
151
|
+
f"affected builds are {event['builds']} and affected jobs are {event['jobs']}"
|
|
152
|
+
)
|
|
153
|
+
case _:
|
|
154
|
+
raise UnknownLogFormat(f"Unknown format {event_type} for event: {event}")
|
|
155
|
+
|
|
156
|
+
return ""
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _format_job_event(event: dict[str, Any]) -> str:
|
|
160
|
+
if "event_type" not in event:
|
|
161
|
+
raise UnknownLogFormat(f"No `event_type` found in job event: {event}")
|
|
162
|
+
|
|
163
|
+
event_type = event["event_type"]
|
|
164
|
+
match event_type:
|
|
165
|
+
case "created" | "deleted" | "updated":
|
|
166
|
+
return _format_job_event_with_message(event=event)
|
|
167
|
+
case "restarted":
|
|
168
|
+
return _format_job_event_without_message(event=event)
|
|
169
|
+
case _:
|
|
170
|
+
raise UnknownLogFormat(f"Unknown format {event_type} for event: {event}")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _format_job_event_without_message(event: dict[str, Any]) -> str:
|
|
174
|
+
return (
|
|
175
|
+
f"{event['datetime']} "
|
|
176
|
+
f"JOB {event['event_type'].upper()}: "
|
|
177
|
+
f"job '{event['job_name']}' {event['event_type']} by user '{event['user_name']}'"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _format_job_event_with_message(event: dict[str, Any]) -> str:
|
|
182
|
+
prefix = _format_job_event_without_message(event=event)
|
|
183
|
+
return f"{prefix}, with message (format: {event['message_format']}) '{event['message']}'"
|