logfire-fleet-client 0.2.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.
- logfire_fleet_client/__init__.py +21 -0
- logfire_fleet_client/_client.py +592 -0
- logfire_fleet_client/_core.py +130 -0
- logfire_fleet_client/_executor.py +701 -0
- logfire_fleet_client/_files.py +235 -0
- logfire_fleet_client/_native.py +152 -0
- logfire_fleet_client/_proto/__init__.py +0 -0
- logfire_fleet_client/_proto/opamp/__init__.py +0 -0
- logfire_fleet_client/_proto/opamp/core/__init__.py +0 -0
- logfire_fleet_client/_proto/opamp/core/v1/__init__.py +0 -0
- logfire_fleet_client/_proto/opamp/core/v1/core_pb2.py +150 -0
- logfire_fleet_client/_proto/opamp/core/v1/core_pb2.pyi +806 -0
- logfire_fleet_client/_proto/opamp/sdk/__init__.py +0 -0
- logfire_fleet_client/_proto/opamp/sdk/v1/__init__.py +0 -0
- logfire_fleet_client/_proto/opamp/sdk/v1/settings_pb2.py +127 -0
- logfire_fleet_client/_proto/opamp/sdk/v1/settings_pb2.pyi +534 -0
- logfire_fleet_client/_proto/opamp/v1/__init__.py +0 -0
- logfire_fleet_client/_proto/opamp/v1/anyvalue_pb2.py +43 -0
- logfire_fleet_client/_proto/opamp/v1/anyvalue_pb2.pyi +45 -0
- logfire_fleet_client/_proto/opamp/v1/opamp_pb2.py +162 -0
- logfire_fleet_client/_proto/opamp/v1/opamp_pb2.pyi +591 -0
- logfire_fleet_client/_streams.py +663 -0
- logfire_fleet_client/_transport.py +605 -0
- logfire_fleet_client/_windows.py +215 -0
- logfire_fleet_client/sdk.py +129 -0
- logfire_fleet_client/setup.py +62 -0
- logfire_fleet_client-0.2.0.dist-info/METADATA +236 -0
- logfire_fleet_client-0.2.0.dist-info/RECORD +29 -0
- logfire_fleet_client-0.2.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Copyright (c) 2026 Pydantic Services Inc.
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""An OpAMP client for Python (asyncio).
|
|
4
|
+
|
|
5
|
+
Everything OpAMP (what to send and when, backoff, heartbeats, full reports, instance UIDs,
|
|
6
|
+
agent_disconnect, and the requests and responses themselves: headers, framing, Retry-After, size
|
|
7
|
+
limits) is the shared OpAMP client core, run natively (logfire_fleet_client._native). By default the
|
|
8
|
+
core speaks HTTP and WebSocket itself too (core mode), over asyncio's TCP connections and TLS
|
|
9
|
+
(``_streams.py``); with ``Config(mode="platform")`` this package carries out the core's requests
|
|
10
|
+
with aiohttp instead. Either way it feeds back what happened, so it behaves exactly like the
|
|
11
|
+
other languages' clients on the same core.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from ._client import Client, Config, Notification, Status
|
|
15
|
+
from ._core import CoreError
|
|
16
|
+
from ._executor import save_state
|
|
17
|
+
from ._transport import TLS, Proxy
|
|
18
|
+
from ._proto.opamp.v1 import anyvalue_pb2, opamp_pb2
|
|
19
|
+
|
|
20
|
+
__all__ = ["TLS", "Client", "Config", "CoreError", "Notification", "Proxy", "Status", "anyvalue_pb2", "opamp_pb2",
|
|
21
|
+
"save_state"]
|
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
# Copyright (c) 2026 Pydantic Services Inc.
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""The client: feeds the core, carries out its effects, runs its timer."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import os
|
|
9
|
+
import ssl
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from collections import deque
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from dataclasses import dataclass, field, fields
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import aiohttp
|
|
18
|
+
|
|
19
|
+
from ._core import Core
|
|
20
|
+
from ._executor import Executor, clear_downloads, read_state
|
|
21
|
+
from ._files import REAL, AfterClear, Files, Journal
|
|
22
|
+
from ._proto.opamp.core.v1 import core_pb2 as corepb
|
|
23
|
+
from ._proto.opamp.v1 import opamp_pb2 as pb
|
|
24
|
+
from ._streams import Streams, proxy_choice
|
|
25
|
+
from ._transport import TLS, Proxy, Transport, redacted_headers, redacted_url
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _u64(n: int) -> int:
|
|
29
|
+
"""A time or size for the core: a negative one reaches it as its two's complement, which the core
|
|
30
|
+
refuses at start, naming the field and the number meant (as in every SDK)."""
|
|
31
|
+
return n & 0xFFFF_FFFF_FFFF_FFFF
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Config:
|
|
36
|
+
"""The client's settings."""
|
|
37
|
+
|
|
38
|
+
#: The server URL: http(s)://... for plain HTTP, ws(s)://... for WebSocket.
|
|
39
|
+
endpoint: str
|
|
40
|
+
#: The instance UID to start with (16 bytes). The server may assign another. None: the core
|
|
41
|
+
#: makes one (a UUIDv7, from the clock and the operating system's randomness), and with a
|
|
42
|
+
#: state_file keeps it across restarts. ``Client.instance_uid`` says which is in use.
|
|
43
|
+
instance_uid: bytes | None = None
|
|
44
|
+
#: Capabilities the agent supports (AgentCapabilities bits), fixed for the run. ReportsStatus is
|
|
45
|
+
#: always added, and the core adds the bits of what it does itself (packages, connection
|
|
46
|
+
#: settings). Declare ReportsHeartbeat for heartbeats, and the Reports* bit of each status you
|
|
47
|
+
#: set: a status whose bit isn't declared isn't sent.
|
|
48
|
+
capabilities: int = 0
|
|
49
|
+
#: Heartbeat (WebSocket) or polling (HTTP) interval until the server offers one; 0: 30 s.
|
|
50
|
+
heartbeat_interval_ms: int = 0
|
|
51
|
+
#: Sent with every request or upgrade (Authorization, say). One that can't be sent as given (a
|
|
52
|
+
#: name that isn't a token, a control character in a value or whitespace at its ends, one of
|
|
53
|
+
#: the headers the HTTP stack writes) fails each request, with why, in both modes.
|
|
54
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
55
|
+
#: Plain HTTP request timeout; 0: 30 s.
|
|
56
|
+
request_timeout_ms: int = 0
|
|
57
|
+
#: The largest message sent, encoded; 0: 64 MiB, and at least 1 KiB. A status that doesn't fit
|
|
58
|
+
#: is left out (``status_too_large``).
|
|
59
|
+
#: covers: OPAMP-SIZE-02, OPAMP-SIZE-08
|
|
60
|
+
max_send_bytes: int = 0
|
|
61
|
+
#: The largest response or message accepted; 0: 64 MiB.
|
|
62
|
+
#: covers: OPAMP-SIZE-02, OPAMP-SIZE-08
|
|
63
|
+
max_receive_bytes: int = 0
|
|
64
|
+
#: Where the client keeps state across restarts; None: nowhere. It holds secrets (credentials
|
|
65
|
+
#: the server issued, private keys of telemetry settings' certificates), so it is written
|
|
66
|
+
#: readable by the agent's user only (mode 0600). On Windows it takes its directory's
|
|
67
|
+
#: permissions: keep it on a local path only the agent's user can read.
|
|
68
|
+
state_file: str | None = None
|
|
69
|
+
#: Take offers of new connection settings from the server (docs/connection-settings.md).
|
|
70
|
+
#: Offers are refused without a state_file: a restart would lose them.
|
|
71
|
+
accept_connection_settings: bool = False
|
|
72
|
+
#: How long offered settings have to prove themselves; 0: 60 s.
|
|
73
|
+
verify_timeout_ms: int = 0
|
|
74
|
+
#: Take packages from the server (docs/packages.md): the client downloads them to
|
|
75
|
+
#: ``state_file + ".downloads"`` and the application installs them (``install_package``,
|
|
76
|
+
#: ``remove_package``). Only with a state_file: a restart would forget what the agent has.
|
|
77
|
+
accept_packages: bool = False
|
|
78
|
+
#: Ask the server to assign the instance UID (the RequestInstanceUid flag,
|
|
79
|
+
#: docs/instance-uid.md). The UID the client starts with (made up, or instance_uid) is the
|
|
80
|
+
#: temporary value until the server assigns one. With a state_file the assigned UID is kept,
|
|
81
|
+
#: and a restart doesn't ask again; without one every start asks again, and the server assigns
|
|
82
|
+
#: a new UID each time: the agent is a new agent after every restart.
|
|
83
|
+
#: covers: OPAMP-UID-04
|
|
84
|
+
request_instance_uid: bool = False
|
|
85
|
+
#: The largest package file downloaded; 0: 1 GiB.
|
|
86
|
+
max_package_bytes: int = 0
|
|
87
|
+
#: How long a download may take; 0: 10 minutes.
|
|
88
|
+
download_timeout_ms: int = 0
|
|
89
|
+
#: How long stop may take (the goodbye, and any closing handshake); 0: 5 s.
|
|
90
|
+
stop_timeout_ms: int = 0
|
|
91
|
+
#: How long a WebSocket connection may take to open; 0: 30 s.
|
|
92
|
+
connect_timeout_ms: int = 0
|
|
93
|
+
#: TLS for https:// and wss:// endpoints, https:// proxies and package downloads, in both
|
|
94
|
+
#: modes: a TLS (the CAs to trust, a client certificate, versions), or an ssl.SSLContext used
|
|
95
|
+
#: as it is. None: the system's CAs, TLS 1.2 or later.
|
|
96
|
+
#: covers: RULE-TLS-01, RULE-TLS-02
|
|
97
|
+
tls: TLS | ssl.SSLContext | None = None
|
|
98
|
+
#: The proxy to reach the server and download packages through: a Proxy, or just its URL
|
|
99
|
+
#: (``http://``, ``https://``, ``socks5://`` or ``socks5h://``, with ``user:password@`` if
|
|
100
|
+
#: it needs them; through SOCKS5 the proxy resolves the server's name, unless the Proxy has
|
|
101
|
+
#: resolve_locally). None: the environment's, read at start (HTTP_PROXY for http:// and
|
|
102
|
+
#: ws://, HTTPS_PROXY for https:// and wss://, ALL_PROXY for either when its own is unset,
|
|
103
|
+
#: NO_PROXY: names, addresses and CIDR networks), by the rule every SDK's core mode shares; in
|
|
104
|
+
#: platform mode as urllib reads them (lower-case names first, name:port entries too).
|
|
105
|
+
#: Proxy.DIRECT: none at all.
|
|
106
|
+
#: covers: RULE-PROXY-01
|
|
107
|
+
proxy: Proxy | str | None = None
|
|
108
|
+
#: Who speaks HTTP and WebSocket. ``"core"`` (the default): the shared core itself,
|
|
109
|
+
#: over asyncio's TCP connections and TLS with the settings above; Python
|
|
110
|
+
#: only opens connections, starts TLS, picks the proxy and writes package files. ``"platform"``:
|
|
111
|
+
#: aiohttp, for an application that needs Python's own stack (urllib's reading of the
|
|
112
|
+
#: environment's proxies, say).
|
|
113
|
+
mode: str = "core"
|
|
114
|
+
#: Core mode: package files go from the socket to the file without passing through the core
|
|
115
|
+
#: (where the response says how long the body is). It is core mode's: False in platform mode is
|
|
116
|
+
#: an error.
|
|
117
|
+
splice_downloads: bool = True
|
|
118
|
+
|
|
119
|
+
def __repr__(self) -> str:
|
|
120
|
+
# Without the secrets: the headers' values (Authorization, say) and a proxy URL's password.
|
|
121
|
+
def shown(name: str, value: object) -> object:
|
|
122
|
+
if name == "headers":
|
|
123
|
+
return redacted_headers(self.headers)
|
|
124
|
+
if name == "proxy" and isinstance(value, str):
|
|
125
|
+
return redacted_url(value)
|
|
126
|
+
return value
|
|
127
|
+
|
|
128
|
+
parts = (f"{f.name}={shown(f.name, getattr(self, f.name))!r}" for f in fields(self))
|
|
129
|
+
return f"Config({', '.join(parts)})"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass
|
|
133
|
+
class Status:
|
|
134
|
+
"""The agent's status as the client starts. The first message reports it in full."""
|
|
135
|
+
|
|
136
|
+
description: pb.AgentDescription | None = None
|
|
137
|
+
health: pb.ComponentHealth | None = None
|
|
138
|
+
effective_config: pb.EffectiveConfig | None = None
|
|
139
|
+
remote_config_status: pb.RemoteConfigStatus | None = None
|
|
140
|
+
# The components the agent has (leave `hash` empty: the core computes it). The server gets the
|
|
141
|
+
# hash, and the components in full when it asks.
|
|
142
|
+
available_components: pb.AvailableComponents | None = None
|
|
143
|
+
# The custom capabilities the agent supports: custom messages for them reach the application,
|
|
144
|
+
# and it may send them.
|
|
145
|
+
custom_capabilities: pb.CustomCapabilities | None = None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass
|
|
149
|
+
class Notification:
|
|
150
|
+
"""What the application is told. `kind` says which; the other fields go with it:
|
|
151
|
+
|
|
152
|
+
- ``connected``: a WebSocket connected, or an HTTP request succeeded (the first, or the first
|
|
153
|
+
after failures)
|
|
154
|
+
- ``connect_failed``: ``status`` (an HTTP status, if there was one), ``detail``
|
|
155
|
+
- ``error_response``: ``error_type`` (ServerErrorResponseType), ``message``
|
|
156
|
+
- ``remote_config``: ``remote_config`` (AgentRemoteConfig): answer with set_remote_config_status
|
|
157
|
+
- ``custom_message``: ``custom_message``, from the server, for a declared custom capability
|
|
158
|
+
- ``server_custom_capabilities``: ``custom_capabilities`` (the server's, a list of strings)
|
|
159
|
+
- ``custom_message_not_sent``: ``custom_message``, ``detail`` (why it won't be sent)
|
|
160
|
+
- ``connection_settings``: ``connection_settings`` (ConnectionSettingsOffers: own telemetry and
|
|
161
|
+
other connection settings, all of them): answer with set_connection_settings_status
|
|
162
|
+
- ``install_package``: ``package_name``, ``package`` (PackageAvailable: version, hash, type, the
|
|
163
|
+
file's signature), ``path`` (the downloaded file): install it (checking the signature is the
|
|
164
|
+
application's job), then answer with package_installed; the client deletes the file after
|
|
165
|
+
- ``remove_package``: ``package_name``, no longer offered: remove it, then answer with
|
|
166
|
+
package_removed
|
|
167
|
+
- ``command``: ``command`` (ServerToAgentCommand)
|
|
168
|
+
- ``instance_uid_changed``: ``instance_uid``
|
|
169
|
+
- ``heartbeat_interval``: ``heartbeat_interval_ms`` (0: heartbeats off)
|
|
170
|
+
- ``status_too_large``: ``field`` (the field's name in AgentToServer, as the core gives it:
|
|
171
|
+
agent_description, health, effective_config, ...), ``bytes``
|
|
172
|
+
- ``bad_message``: ``detail``
|
|
173
|
+
- ``save_stalled``: ``stalled`` (True: the save under way has run for its stall time, and the
|
|
174
|
+
client waits on it still, saving nothing else meanwhile; False: it has ended at last),
|
|
175
|
+
``waited_ms`` (how long it had run). A disk that hangs is the application's to see to.
|
|
176
|
+
- ``state_from_newer_version``: ``state_version`` (the saved state's format, a newer core's,
|
|
177
|
+
which this one can't read): the client starts from its configuration, as on first use, and
|
|
178
|
+
saves nothing (no offered connection settings, no packages), leaving that state as it is.
|
|
179
|
+
Worth a warning: an older release was started on a newer one's state file.
|
|
180
|
+
- ``stopped``: ``error`` (why the client stopped on its own, which stop() raises too; None
|
|
181
|
+
after stop())
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
kind: str
|
|
185
|
+
status: int | None = None
|
|
186
|
+
detail: str = ""
|
|
187
|
+
error_type: int = 0
|
|
188
|
+
message: str = ""
|
|
189
|
+
remote_config: pb.AgentRemoteConfig | None = None
|
|
190
|
+
connection_settings: pb.ConnectionSettingsOffers | None = None
|
|
191
|
+
custom_message: pb.CustomMessage | None = None
|
|
192
|
+
custom_capabilities: list[str] | None = None
|
|
193
|
+
command: pb.ServerToAgentCommand | None = None
|
|
194
|
+
package_name: str = ""
|
|
195
|
+
package: pb.PackageAvailable | None = None
|
|
196
|
+
path: str = ""
|
|
197
|
+
instance_uid: bytes = b""
|
|
198
|
+
heartbeat_interval_ms: int = 0
|
|
199
|
+
field: str = ""
|
|
200
|
+
bytes: int = 0
|
|
201
|
+
stalled: bool = False
|
|
202
|
+
waited_ms: int = 0
|
|
203
|
+
state_version: int = 0
|
|
204
|
+
error: BaseException | None = None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
#: Once the core has stopped, at least this for what it gave up on to unwind (a download's file
|
|
208
|
+
#: deleted once the write in flight is over, a cancelled request's task ended), however much of
|
|
209
|
+
#: the stop timeout the core took: stop returns within stop_timeout_ms and this.
|
|
210
|
+
STOP_GRACE_S = 1.0
|
|
211
|
+
|
|
212
|
+
# The inputs that report an effect's outcome, each naming the effect by its id.
|
|
213
|
+
_RESULTS = frozenset({"http_response", "http_failed", "ws_opened", "ws_failed", "ws_message", "ws_closed", "ws_lost",
|
|
214
|
+
"stored", "store_failed", "downloaded", "download_failed", "download_response"})
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _names(input_: corepb.Input, id_: int) -> bool:
|
|
218
|
+
"""Whether `input_` reports on effect `id_`."""
|
|
219
|
+
kind = input_.WhichOneof("kind")
|
|
220
|
+
return kind in _RESULTS and getattr(input_, kind).id == id_
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _notification(n: corepb.Notification, download_path: Callable[[int], str]) -> Notification | None:
|
|
224
|
+
kind = n.WhichOneof("kind")
|
|
225
|
+
if kind == "connected":
|
|
226
|
+
return Notification("connected")
|
|
227
|
+
if kind == "connect_failed":
|
|
228
|
+
status = n.connect_failed.status if n.connect_failed.HasField("status") else None
|
|
229
|
+
return Notification("connect_failed", status=status, detail=n.connect_failed.detail)
|
|
230
|
+
if kind == "error_response":
|
|
231
|
+
return Notification("error_response", error_type=n.error_response.kind, message=n.error_response.message)
|
|
232
|
+
if kind == "remote_config":
|
|
233
|
+
return Notification("remote_config", remote_config=n.remote_config)
|
|
234
|
+
if kind == "custom_message":
|
|
235
|
+
return Notification("custom_message", custom_message=n.custom_message)
|
|
236
|
+
if kind == "server_custom_capabilities":
|
|
237
|
+
return Notification("server_custom_capabilities",
|
|
238
|
+
custom_capabilities=list(n.server_custom_capabilities.capabilities))
|
|
239
|
+
if kind == "custom_message_not_sent":
|
|
240
|
+
return Notification("custom_message_not_sent", custom_message=n.custom_message_not_sent.message,
|
|
241
|
+
detail=n.custom_message_not_sent.reason)
|
|
242
|
+
if kind == "connection_settings":
|
|
243
|
+
return Notification("connection_settings", connection_settings=n.connection_settings)
|
|
244
|
+
if kind == "install_package":
|
|
245
|
+
p = n.install_package
|
|
246
|
+
return Notification("install_package", package_name=p.name, package=p.package,
|
|
247
|
+
path=download_path(p.download))
|
|
248
|
+
if kind == "remove_package":
|
|
249
|
+
return Notification("remove_package", package_name=n.remove_package.name)
|
|
250
|
+
if kind == "command":
|
|
251
|
+
return Notification("command", command=n.command)
|
|
252
|
+
if kind == "instance_uid_changed":
|
|
253
|
+
return Notification("instance_uid_changed", instance_uid=bytes(n.instance_uid_changed))
|
|
254
|
+
if kind == "heartbeat_interval_ms":
|
|
255
|
+
return Notification("heartbeat_interval", heartbeat_interval_ms=n.heartbeat_interval_ms)
|
|
256
|
+
if kind == "status_too_large":
|
|
257
|
+
s = n.status_too_large
|
|
258
|
+
return Notification("status_too_large", field=s.name, bytes=s.bytes)
|
|
259
|
+
if kind == "bad_message":
|
|
260
|
+
return Notification("bad_message", detail=n.bad_message.detail)
|
|
261
|
+
if kind == "save_stalled":
|
|
262
|
+
return Notification("save_stalled", stalled=n.save_stalled.stalled, waited_ms=n.save_stalled.waited_ms)
|
|
263
|
+
if kind == "state_from_newer_version":
|
|
264
|
+
return Notification("state_from_newer_version", state_version=n.state_from_newer_version.version)
|
|
265
|
+
if kind == "stopped":
|
|
266
|
+
return Notification("stopped")
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class Client:
|
|
271
|
+
"""A running OpAMP client, on the current asyncio event loop. Use :meth:`start`. Its methods
|
|
272
|
+
may be called from any thread (a call from another thread is carried out on the loop), and
|
|
273
|
+
:meth:`stop` awaited on any loop (on another, it is carried out on the client's)."""
|
|
274
|
+
|
|
275
|
+
def __init__(self, config: Config, status: Status, notify: Callable[[Notification], None] | None,
|
|
276
|
+
_token: object, _core_engine: Any = None, _files: Files | None = None,
|
|
277
|
+
_sessions: Callable[[str], aiohttp.ClientSession] | None = None) -> None:
|
|
278
|
+
if _token is not _START:
|
|
279
|
+
raise TypeError("use Client.start")
|
|
280
|
+
# First: settings that can't work fail here, before anything is done.
|
|
281
|
+
# Nothing one mode alone honours is ignored: it is an error that says what to set.
|
|
282
|
+
if config.mode not in ("core", "platform"):
|
|
283
|
+
raise ValueError(f'mode is "core" or "platform", not {config.mode!r}')
|
|
284
|
+
core_mode = config.mode == "core"
|
|
285
|
+
if not core_mode and not config.splice_downloads:
|
|
286
|
+
raise ValueError('splice_downloads in platform mode: it is core mode\'s (aiohttp writes package files itself); '
|
|
287
|
+
'leave it True, or leave mode as it is ("core")')
|
|
288
|
+
transport = Transport(config.tls, config.proxy, core_mode=core_mode)
|
|
289
|
+
# The core takes the transport from the endpoint's scheme, keeps every effect's time (and
|
|
290
|
+
# cancels what it gives up on), decides what a download's status means, and makes the
|
|
291
|
+
# instance UID if the application chose none, and the backoff's jitter seed from the
|
|
292
|
+
# entropy (seed left 0: two clients started in the same tick don't retry in step).
|
|
293
|
+
self._core = Core(corepb.Config(
|
|
294
|
+
instance_uid=config.instance_uid or b"",
|
|
295
|
+
entropy=os.urandom(32),
|
|
296
|
+
unix_ms=time.time_ns() // 1_000_000,
|
|
297
|
+
stop_timeout_ms=_u64(config.stop_timeout_ms),
|
|
298
|
+
connect_timeout_ms=_u64(config.connect_timeout_ms),
|
|
299
|
+
capabilities=config.capabilities,
|
|
300
|
+
heartbeat_interval_ms=_u64(config.heartbeat_interval_ms),
|
|
301
|
+
endpoint=config.endpoint,
|
|
302
|
+
headers=[corepb.Header(name=k, value=v) for k, v in config.headers.items()],
|
|
303
|
+
request_timeout_ms=_u64(config.request_timeout_ms),
|
|
304
|
+
max_send_bytes=_u64(config.max_send_bytes),
|
|
305
|
+
max_receive_bytes=_u64(config.max_receive_bytes),
|
|
306
|
+
persistence=config.state_file is not None,
|
|
307
|
+
saved_state=read_state(config.state_file),
|
|
308
|
+
accept_connection_settings=config.accept_connection_settings,
|
|
309
|
+
verify_timeout_ms=_u64(config.verify_timeout_ms),
|
|
310
|
+
accept_packages=config.accept_packages,
|
|
311
|
+
request_instance_uid=config.request_instance_uid,
|
|
312
|
+
max_package_bytes=_u64(config.max_package_bytes),
|
|
313
|
+
download_timeout_ms=_u64(config.download_timeout_ms),
|
|
314
|
+
streams=core_mode,
|
|
315
|
+
splice_downloads=core_mode and config.splice_downloads,
|
|
316
|
+
proxy=proxy_choice(transport) if core_mode else None,
|
|
317
|
+
), _engine=_core_engine)
|
|
318
|
+
# All of stop takes the core's stop timeout, as given or by default: its start reports it.
|
|
319
|
+
self._stop_timeout = (self._core.limits.stop_timeout_ms if self._core.limits else 0) / 1000
|
|
320
|
+
self._stop_called: float | None = None # when stop() was first called (the loop's clock)
|
|
321
|
+
# The file system saves and download files go through; with the tap on, each operation
|
|
322
|
+
# is recorded there too, the state file named first.
|
|
323
|
+
files = _files or REAL
|
|
324
|
+
if config.state_file is not None and self._core.tapped:
|
|
325
|
+
files = Journal(files, self._core.record_file, config.state_file)
|
|
326
|
+
if config.state_file is not None:
|
|
327
|
+
# Files downloaded before a restart: deleted beside the loop (a slow disk holds up
|
|
328
|
+
# neither start nor the application), and before this client makes any of its own.
|
|
329
|
+
files = AfterClear(files, clear_downloads(files, config.state_file))
|
|
330
|
+
self._notify = notify or (lambda _n: None)
|
|
331
|
+
# Notifications from while the client is starting: delivered once `start` has returned.
|
|
332
|
+
self._held: list[Notification] | None = []
|
|
333
|
+
self._loop = asyncio.get_running_loop()
|
|
334
|
+
self._thread = threading.get_ident()
|
|
335
|
+
# The loop's clock (time.monotonic, unless a test's loop keeps its own).
|
|
336
|
+
self._start = self._loop.time()
|
|
337
|
+
self._x = Executor(self._feed, config.state_file, transport, files, sessions=_sessions)
|
|
338
|
+
# Core mode's streams and files; None otherwise.
|
|
339
|
+
self._streams = Streams(self._feed, transport, config.state_file, client=self._x.client,
|
|
340
|
+
files=files) if core_mode else None
|
|
341
|
+
self._queue: deque[corepb.Input] = deque()
|
|
342
|
+
self._draining = False
|
|
343
|
+
self._timer: asyncio.TimerHandle | None = None
|
|
344
|
+
self._done = self._loop.create_future()
|
|
345
|
+
self._finished = False
|
|
346
|
+
self._said_stopped = False
|
|
347
|
+
for name in ("description", "health", "effective_config", "remote_config_status", "available_components",
|
|
348
|
+
"custom_capabilities"):
|
|
349
|
+
value = getattr(status, name)
|
|
350
|
+
if value is not None:
|
|
351
|
+
self._feed(corepb.Input(**{f"set_{name}": value}))
|
|
352
|
+
self._feed(corepb.Input(start=corepb.Empty()))
|
|
353
|
+
self._loop.call_soon(self._release)
|
|
354
|
+
|
|
355
|
+
@classmethod
|
|
356
|
+
def start(cls, config: Config, status: Status,
|
|
357
|
+
notify: Callable[[Notification], None] | None = None, *,
|
|
358
|
+
_core_engine: Any = None, _files: Files | None = None,
|
|
359
|
+
_sessions: Callable[[str], aiohttp.ClientSession] | None = None) -> Client:
|
|
360
|
+
"""Start a client on the running event loop. `notify` is called for every notification,
|
|
361
|
+
on the loop, never before `start` returns (a saved remote config is handed over at start):
|
|
362
|
+
keep it quick. TLS or proxy settings that can't work raise here: ValueError, or OSError
|
|
363
|
+
and ssl.SSLError for files and PEM that can't be loaded."""
|
|
364
|
+
# Tests only: _core_engine, run instead of the native core (the adversary); _files, a
|
|
365
|
+
# file system standing in for the real one; _sessions (Executor's).
|
|
366
|
+
return cls(config, status, notify, _START, _core_engine, _files, _sessions)
|
|
367
|
+
|
|
368
|
+
@property
|
|
369
|
+
def instance_uid(self) -> bytes:
|
|
370
|
+
"""The instance UID in use: the application's, one saved before a restart, one the core
|
|
371
|
+
made, or one the server assigned (``instance_uid_changed`` tells of a new one)."""
|
|
372
|
+
return self._core.instance_uid
|
|
373
|
+
|
|
374
|
+
@property
|
|
375
|
+
def endpoint(self) -> str | None:
|
|
376
|
+
"""The endpoint the client uses now: where its latest OpAMP request or WebSocket connection
|
|
377
|
+
went, which after the server moved the agent (an offer of connection settings) is the
|
|
378
|
+
offered one, or the configured one again once an offer failed. None before any outcome,
|
|
379
|
+
and in core mode, where the core makes the requests itself (the configured one is all
|
|
380
|
+
there is to name then). The URL is as the core gave it: redact it before logging."""
|
|
381
|
+
return None if self._streams is not None else self._x.used
|
|
382
|
+
|
|
383
|
+
def _release(self) -> None:
|
|
384
|
+
"""Deliver what came while starting, now that the caller has the client."""
|
|
385
|
+
held, self._held = self._held or [], None
|
|
386
|
+
try:
|
|
387
|
+
for n in held:
|
|
388
|
+
self._notify(n)
|
|
389
|
+
except BaseException as e: # the application's callback
|
|
390
|
+
self._failed(e)
|
|
391
|
+
|
|
392
|
+
def set_description(self, value: pb.AgentDescription) -> None:
|
|
393
|
+
"""Change the agent description."""
|
|
394
|
+
self._call(corepb.Input(set_description=value))
|
|
395
|
+
|
|
396
|
+
def set_health(self, value: pb.ComponentHealth) -> None:
|
|
397
|
+
"""Change the health."""
|
|
398
|
+
self._call(corepb.Input(set_health=value))
|
|
399
|
+
|
|
400
|
+
def set_effective_config(self, value: pb.EffectiveConfig) -> None:
|
|
401
|
+
"""Change the effective config."""
|
|
402
|
+
self._call(corepb.Input(set_effective_config=value))
|
|
403
|
+
|
|
404
|
+
def set_custom_capabilities(self, value: pb.CustomCapabilities) -> None:
|
|
405
|
+
"""Change the custom capabilities the agent supports."""
|
|
406
|
+
self._call(corepb.Input(set_custom_capabilities=value))
|
|
407
|
+
|
|
408
|
+
def send_custom_message(self, value: pb.CustomMessage) -> None:
|
|
409
|
+
"""Send a custom message, for one of the agent's custom capabilities. It goes with the next
|
|
410
|
+
message the server takes, and again if that message is lost; if it can't be sent at all, a
|
|
411
|
+
custom_message_not_sent notification says why."""
|
|
412
|
+
self._call(corepb.Input(send_custom_message=value))
|
|
413
|
+
|
|
414
|
+
def set_available_components(self, value: pb.AvailableComponents) -> None:
|
|
415
|
+
"""Change the components the agent has (leave `hash` empty: the core computes it)."""
|
|
416
|
+
self._call(corepb.Input(set_available_components=value))
|
|
417
|
+
|
|
418
|
+
def set_remote_config_status(self, value: pb.RemoteConfigStatus) -> None:
|
|
419
|
+
"""Report on a remote config (the answer to a remote_config notification)."""
|
|
420
|
+
self._call(corepb.Input(set_remote_config_status=value))
|
|
421
|
+
|
|
422
|
+
def set_connection_settings_status(self, value: pb.ConnectionSettingsStatus) -> None:
|
|
423
|
+
"""Report on telemetry settings (the answer to a connection_settings notification)."""
|
|
424
|
+
self._call(corepb.Input(set_connection_settings_status=value))
|
|
425
|
+
|
|
426
|
+
def package_installed(self, name: str, hash_: bytes, error: str | None = None) -> None:
|
|
427
|
+
"""Answer an install_package notification: the package at `hash_` is installed, or (`error`)
|
|
428
|
+
why it isn't."""
|
|
429
|
+
self._call(corepb.Input(package_installed=corepb.PackageInstalled(
|
|
430
|
+
name=name, hash=hash_, ok=error is None, detail=error or "")))
|
|
431
|
+
|
|
432
|
+
def package_removed(self, name: str, error: str | None = None) -> None:
|
|
433
|
+
"""Answer a remove_package notification: the package is removed, or (`error`) why it isn't.
|
|
434
|
+
One that isn't is reported failed, and not removed again until it is offered and dropped
|
|
435
|
+
again."""
|
|
436
|
+
self._call(corepb.Input(package_removed=corepb.PackageRemoved(
|
|
437
|
+
name=name, failed=error is not None, detail=error or "")))
|
|
438
|
+
|
|
439
|
+
async def stop(self) -> None:
|
|
440
|
+
"""Send agent_disconnect if a message can still be sent, then stop: this returns when the
|
|
441
|
+
core says it has stopped, within ``stop_timeout_ms`` (5 s by default). Nothing the client
|
|
442
|
+
started is running then. Awaited on another event loop (another thread's), it is carried
|
|
443
|
+
out on the client's, and returns (or raises) as it does there."""
|
|
444
|
+
if asyncio.get_running_loop() is not self._loop:
|
|
445
|
+
# The core is stepped on the client's loop only, and its futures are that loop's: fed
|
|
446
|
+
# from here, the stop raced the loop's own steps, and the await raised (a future of
|
|
447
|
+
# another loop) with the stop half done.
|
|
448
|
+
if not self._done.done():
|
|
449
|
+
if not self._loop.is_running():
|
|
450
|
+
raise RuntimeError("stop: the client's event loop isn't running")
|
|
451
|
+
there = asyncio.run_coroutine_threadsafe(self.stop(), self._loop)
|
|
452
|
+
here = asyncio.wrap_future(there)
|
|
453
|
+
# Waited on while the client's loop runs: a loop stopped before it ran the stop
|
|
454
|
+
# would leave it waiting forever.
|
|
455
|
+
while not (await asyncio.wait({here}, timeout=0.1))[0]:
|
|
456
|
+
if not self._loop.is_running():
|
|
457
|
+
there.cancel()
|
|
458
|
+
raise RuntimeError("stop: the client's event loop stopped before the stop was done")
|
|
459
|
+
here.result()
|
|
460
|
+
return self._done.result()
|
|
461
|
+
if self._stop_called is None:
|
|
462
|
+
self._stop_called = self._loop.time()
|
|
463
|
+
self._feed(corepb.Input(stop=corepb.Empty()))
|
|
464
|
+
await asyncio.shield(self._done)
|
|
465
|
+
|
|
466
|
+
def _call(self, input_: corepb.Input) -> None:
|
|
467
|
+
"""An application's call: fed now on the loop's thread, from another thread on the loop."""
|
|
468
|
+
if threading.get_ident() == self._thread:
|
|
469
|
+
self._feed(input_)
|
|
470
|
+
elif not self._finished:
|
|
471
|
+
try:
|
|
472
|
+
self._loop.call_soon_threadsafe(self._feed, input_)
|
|
473
|
+
except RuntimeError:
|
|
474
|
+
# The loop is closed: the client can never step again, and a call after stop does
|
|
475
|
+
# nothing, from whichever thread it comes (on the loop's thread it already did).
|
|
476
|
+
if not self._loop.is_closed():
|
|
477
|
+
raise
|
|
478
|
+
|
|
479
|
+
def _now(self) -> int:
|
|
480
|
+
return int((self._loop.time() - self._start) * 1000)
|
|
481
|
+
|
|
482
|
+
def _feed(self, next_: corepb.Input) -> None:
|
|
483
|
+
"""Step the core with every queued input, in order, carrying out the effects."""
|
|
484
|
+
if self._finished:
|
|
485
|
+
return
|
|
486
|
+
self._queue.append(next_)
|
|
487
|
+
if self._draining:
|
|
488
|
+
return
|
|
489
|
+
self._draining = True
|
|
490
|
+
try:
|
|
491
|
+
while self._queue and not self._finished:
|
|
492
|
+
out = self._core.step(self._now(), self._queue.popleft())
|
|
493
|
+
# stopped is told last: the core may emit a notification after it in the same
|
|
494
|
+
# output (install_package, in the step that answers a save after stop), and
|
|
495
|
+
# nothing reaches the application after stopped (RULE-SHELL-01).
|
|
496
|
+
stopped: Notification | None = None
|
|
497
|
+
for effect in out.effects:
|
|
498
|
+
kind = effect.WhichOneof("kind")
|
|
499
|
+
if kind is None:
|
|
500
|
+
# A newer module than this client: fail loudly rather than skip what the
|
|
501
|
+
# core asked for.
|
|
502
|
+
raise RuntimeError("the core asked for an effect this client doesn't know: the client is older than its core")
|
|
503
|
+
if kind == "notify":
|
|
504
|
+
n = _notification(effect.notify, self._x.download_path)
|
|
505
|
+
if n is not None and n.kind == "stopped":
|
|
506
|
+
stopped = n
|
|
507
|
+
elif n is not None:
|
|
508
|
+
self._tell(n)
|
|
509
|
+
else:
|
|
510
|
+
if kind == "cancel":
|
|
511
|
+
# Nothing more about it reaches the core, not even what is queued.
|
|
512
|
+
# covers: RULE-CLIENT-15
|
|
513
|
+
id_ = effect.cancel.id
|
|
514
|
+
self._queue = deque(i for i in self._queue if not _names(i, id_))
|
|
515
|
+
if self._streams is None or not self._streams.do(effect):
|
|
516
|
+
self._x.do(effect)
|
|
517
|
+
if stopped is not None:
|
|
518
|
+
self._tell(stopped)
|
|
519
|
+
if self._timer is not None:
|
|
520
|
+
self._timer.cancel()
|
|
521
|
+
self._timer = None
|
|
522
|
+
if out.HasField("wake_at_ms"):
|
|
523
|
+
delay = max(0, out.wake_at_ms - self._now()) / 1000
|
|
524
|
+
self._timer = self._loop.call_later(delay, self._feed, corepb.Input(timer=corepb.Empty()))
|
|
525
|
+
# Core mode: the core ends its own goodbye and closing handshakes, within its stop
|
|
526
|
+
# time, and has closed every stream by now.
|
|
527
|
+
if out.stopped:
|
|
528
|
+
self._end()
|
|
529
|
+
except BaseException as e: # a CoreError, a bug, or the application's callback
|
|
530
|
+
self._failed(e)
|
|
531
|
+
finally:
|
|
532
|
+
self._draining = False
|
|
533
|
+
|
|
534
|
+
def _failed(self, e: BaseException) -> None:
|
|
535
|
+
"""Something raised: the client stops, and stop() raises it too. KeyboardInterrupt and the
|
|
536
|
+
like go on up after."""
|
|
537
|
+
if not self._finished:
|
|
538
|
+
self._end(e)
|
|
539
|
+
if not isinstance(e, Exception):
|
|
540
|
+
raise e
|
|
541
|
+
|
|
542
|
+
def _tell(self, n: Notification) -> None:
|
|
543
|
+
"""Tell the application, or hold it until start has returned."""
|
|
544
|
+
self._said_stopped |= n.kind == "stopped"
|
|
545
|
+
if self._held is not None:
|
|
546
|
+
self._held.append(n)
|
|
547
|
+
else:
|
|
548
|
+
self._notify(n)
|
|
549
|
+
|
|
550
|
+
def _end(self, err: BaseException | None = None) -> None:
|
|
551
|
+
self._finished = True
|
|
552
|
+
if self._timer is not None:
|
|
553
|
+
self._timer.cancel()
|
|
554
|
+
self._core.close()
|
|
555
|
+
if err is not None and not self._said_stopped:
|
|
556
|
+
# The client stopped on its own: the application hears it now, not only from stop().
|
|
557
|
+
# If its callback is what failed, it may fail again: that is already the error reported.
|
|
558
|
+
try:
|
|
559
|
+
self._tell(Notification("stopped", error=err))
|
|
560
|
+
except Exception:
|
|
561
|
+
pass
|
|
562
|
+
|
|
563
|
+
async def finish() -> None:
|
|
564
|
+
# The core has already waited for the goodbye: anything left is dropped. All of stop
|
|
565
|
+
# takes stop_timeout: what the core took of it (from stop()) is not given again, but
|
|
566
|
+
# for STOP_GRACE_S at least for what was given up on to unwind (RULE-SHELL-07: it is
|
|
567
|
+
# released, the deletion of a download's file included, by the time stop returns). A
|
|
568
|
+
# core that takes all of its stop timeout and then says stopped with a download's file
|
|
569
|
+
# still being written leaves the shell to delete it: given nothing, it was deleted
|
|
570
|
+
# after stop had returned.
|
|
571
|
+
loop = self._loop
|
|
572
|
+
begun = loop.time() if self._stop_called is None else self._stop_called
|
|
573
|
+
left = begun + self._stop_timeout - loop.time()
|
|
574
|
+
released_by = loop.time() + max(left, STOP_GRACE_S)
|
|
575
|
+
# A save still writing lands whole whenever it does: waited for within the stop
|
|
576
|
+
# timeout only. Core mode: the streams go at once, beside it (a slow save holds no
|
|
577
|
+
# connection open), and files still being written are deleted once their write in
|
|
578
|
+
# flight is over.
|
|
579
|
+
work = [self._x.close(max(0.0, left), release=released_by - loop.time())]
|
|
580
|
+
if self._streams is not None:
|
|
581
|
+
work.append(self._streams.shutdown(max(0.0, released_by - loop.time())))
|
|
582
|
+
await asyncio.gather(*work)
|
|
583
|
+
if not self._done.done():
|
|
584
|
+
if err is None:
|
|
585
|
+
self._done.set_result(None)
|
|
586
|
+
else:
|
|
587
|
+
self._done.set_exception(err)
|
|
588
|
+
|
|
589
|
+
self._finish_task = self._loop.create_task(finish())
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
_START = object()
|