reef-vcs 0.4.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.
- reef/__init__.py +70 -0
- reef/_build.py +6 -0
- reef/_calls.py +174 -0
- reef/_channel.py +233 -0
- reef/_connect.py +162 -0
- reef/_env.py +12 -0
- reef/_identity.py +80 -0
- reef/_lease_core.py +104 -0
- reef/_methods.py +3132 -0
- reef/_paths.py +215 -0
- reef/_presence_core.py +208 -0
- reef/_remote.py +194 -0
- reef/_rpc.py +33 -0
- reef/_rpcs.py +528 -0
- reef/_stream.py +56 -0
- reef/_tls.py +195 -0
- reef/aio/__init__.py +17 -0
- reef/aio/_methods.py +3132 -0
- reef/aio/_stream.py +64 -0
- reef/aio/client.py +371 -0
- reef/aio/presence.py +143 -0
- reef/aio/workspace.py +255 -0
- reef/api/__init__.py +1 -0
- reef/api/py.typed +0 -0
- reef/api/v1/__init__.py +217 -0
- reef/api/v1/changes_pb2.py +73 -0
- reef/api/v1/changes_pb2.pyi +565 -0
- reef/api/v1/changes_pb2_grpc.py +271 -0
- reef/api/v1/changes_pb2_grpc.pyi +251 -0
- reef/api/v1/daemon_pb2.py +53 -0
- reef/api/v1/daemon_pb2.pyi +212 -0
- reef/api/v1/daemon_pb2_grpc.py +156 -0
- reef/api/v1/daemon_pb2_grpc.pyi +109 -0
- reef/api/v1/diff_pb2.py +63 -0
- reef/api/v1/diff_pb2.pyi +707 -0
- reef/api/v1/diff_pb2_grpc.py +121 -0
- reef/api/v1/diff_pb2_grpc.pyi +125 -0
- reef/api/v1/error_pb2.py +42 -0
- reef/api/v1/error_pb2.pyi +173 -0
- reef/api/v1/error_pb2_grpc.py +24 -0
- reef/api/v1/error_pb2_grpc.pyi +25 -0
- reef/api/v1/files_pb2.py +71 -0
- reef/api/v1/files_pb2.pyi +569 -0
- reef/api/v1/files_pb2_grpc.py +241 -0
- reef/api/v1/files_pb2_grpc.pyi +250 -0
- reef/api/v1/history_pb2.py +64 -0
- reef/api/v1/history_pb2.pyi +612 -0
- reef/api/v1/history_pb2_grpc.py +114 -0
- reef/api/v1/history_pb2_grpc.pyi +108 -0
- reef/api/v1/maintenance_pb2.py +67 -0
- reef/api/v1/maintenance_pb2.pyi +391 -0
- reef/api/v1/maintenance_pb2_grpc.py +249 -0
- reef/api/v1/maintenance_pb2_grpc.pyi +162 -0
- reef/api/v1/operations_pb2.py +85 -0
- reef/api/v1/operations_pb2.pyi +837 -0
- reef/api/v1/operations_pb2_grpc.py +317 -0
- reef/api/v1/operations_pb2_grpc.pyi +372 -0
- reef/api/v1/options_pb2.py +37 -0
- reef/api/v1/options_pb2.pyi +68 -0
- reef/api/v1/options_pb2_grpc.py +24 -0
- reef/api/v1/options_pb2_grpc.pyi +20 -0
- reef/api/v1/presence_pb2.py +68 -0
- reef/api/v1/presence_pb2.pyi +458 -0
- reef/api/v1/presence_pb2_grpc.py +243 -0
- reef/api/v1/presence_pb2_grpc.pyi +255 -0
- reef/api/v1/remote_pb2.py +79 -0
- reef/api/v1/remote_pb2.pyi +547 -0
- reef/api/v1/remote_pb2_grpc.py +239 -0
- reef/api/v1/remote_pb2_grpc.pyi +124 -0
- reef/api/v1/repositories_pb2.py +66 -0
- reef/api/v1/repositories_pb2.pyi +304 -0
- reef/api/v1/repositories_pb2_grpc.py +237 -0
- reef/api/v1/repositories_pb2_grpc.pyi +118 -0
- reef/api/v1/status_pb2.py +56 -0
- reef/api/v1/status_pb2.pyi +397 -0
- reef/api/v1/status_pb2_grpc.py +125 -0
- reef/api/v1/status_pb2_grpc.pyi +133 -0
- reef/api/v1/stores_pb2.py +62 -0
- reef/api/v1/stores_pb2.pyi +376 -0
- reef/api/v1/stores_pb2_grpc.py +152 -0
- reef/api/v1/stores_pb2_grpc.pyi +99 -0
- reef/api/v1/symbols_pb2.py +85 -0
- reef/api/v1/symbols_pb2.pyi +880 -0
- reef/api/v1/symbols_pb2_grpc.py +231 -0
- reef/api/v1/symbols_pb2_grpc.pyi +225 -0
- reef/api/v1/workspaces_pb2.py +112 -0
- reef/api/v1/workspaces_pb2.pyi +1321 -0
- reef/api/v1/workspaces_pb2_grpc.py +393 -0
- reef/api/v1/workspaces_pb2_grpc.pyi +239 -0
- reef/client.py +440 -0
- reef/daemon/__init__.py +1 -0
- reef/daemon/py.typed +0 -0
- reef/daemon/v0/__init__.py +1 -0
- reef/daemon/v0/daemon_pb2.py +42 -0
- reef/daemon/v0/daemon_pb2.pyi +125 -0
- reef/daemon/v0/daemon_pb2_grpc.py +101 -0
- reef/daemon/v0/daemon_pb2_grpc.pyi +62 -0
- reef/daemon/v0/error_pb2.py +36 -0
- reef/daemon/v0/error_pb2.pyi +67 -0
- reef/daemon/v0/error_pb2_grpc.py +24 -0
- reef/daemon/v0/error_pb2_grpc.pyi +23 -0
- reef/errors.py +409 -0
- reef/presence.py +169 -0
- reef/py.typed +0 -0
- reef/workspace.py +309 -0
- reef_vcs-0.4.0.dist-info/METADATA +124 -0
- reef_vcs-0.4.0.dist-info/RECORD +109 -0
- reef_vcs-0.4.0.dist-info/WHEEL +4 -0
- reef_vcs-0.4.0.dist-info/licenses/LICENSE +202 -0
reef/__init__.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""reef-vcs: the Python client of the Reef daemon (R2.06, ADR 0054).
|
|
2
|
+
|
|
3
|
+
Reef gives each agent its own workspace: a copy-on-write copy of a git or jj repository, leased
|
|
4
|
+
to one process and snapshotted after every write. This package talks to a running daemon (it
|
|
5
|
+
never starts one), over its socket or a remote listener::
|
|
6
|
+
|
|
7
|
+
import reef
|
|
8
|
+
|
|
9
|
+
with reef.connect() as client:
|
|
10
|
+
repo = client.register_repository(path=".").repository
|
|
11
|
+
with client.workspace(repo.id, purpose="fix the parser") as ws:
|
|
12
|
+
(ws.path / "parser.py").write_text("...")
|
|
13
|
+
|
|
14
|
+
Every RPC of ``reef.api.v1`` is a method on :class:`Client` (and, awaited, on
|
|
15
|
+
``reef.aio.AsyncClient``), generated from the schema; messages and enums are importable from
|
|
16
|
+
``reef.api.v1``. Errors are :class:`ReefError` subclasses by kind, with the daemon's ``hint`` and
|
|
17
|
+
``next``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from reef import _env # noqa: F401 # before grpc is imported
|
|
21
|
+
from reef._build import API_REVISION, COMMIT, VERSION
|
|
22
|
+
from reef.client import Client, connect
|
|
23
|
+
from reef.errors import (
|
|
24
|
+
AlreadyExistsError,
|
|
25
|
+
ConfigError,
|
|
26
|
+
ConflictError,
|
|
27
|
+
DaemonNotRunningError,
|
|
28
|
+
ErrorKind,
|
|
29
|
+
IncompatibleError,
|
|
30
|
+
InternalError,
|
|
31
|
+
InvalidArgumentError,
|
|
32
|
+
IoError,
|
|
33
|
+
NotFoundError,
|
|
34
|
+
PermissionDeniedError,
|
|
35
|
+
ReefError,
|
|
36
|
+
RemoteConnectError,
|
|
37
|
+
RemoteMisdirectedError,
|
|
38
|
+
RemoteTlsError,
|
|
39
|
+
UnavailableError,
|
|
40
|
+
UnsupportedError,
|
|
41
|
+
)
|
|
42
|
+
from reef.workspace import WorkspaceHandle
|
|
43
|
+
|
|
44
|
+
__version__ = VERSION
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"API_REVISION",
|
|
48
|
+
"COMMIT",
|
|
49
|
+
"AlreadyExistsError",
|
|
50
|
+
"Client",
|
|
51
|
+
"ConfigError",
|
|
52
|
+
"ConflictError",
|
|
53
|
+
"DaemonNotRunningError",
|
|
54
|
+
"ErrorKind",
|
|
55
|
+
"IncompatibleError",
|
|
56
|
+
"InternalError",
|
|
57
|
+
"InvalidArgumentError",
|
|
58
|
+
"IoError",
|
|
59
|
+
"NotFoundError",
|
|
60
|
+
"PermissionDeniedError",
|
|
61
|
+
"ReefError",
|
|
62
|
+
"RemoteConnectError",
|
|
63
|
+
"RemoteMisdirectedError",
|
|
64
|
+
"RemoteTlsError",
|
|
65
|
+
"UnavailableError",
|
|
66
|
+
"UnsupportedError",
|
|
67
|
+
"WorkspaceHandle",
|
|
68
|
+
"__version__",
|
|
69
|
+
"connect",
|
|
70
|
+
]
|
reef/_build.py
ADDED
reef/_calls.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""What the generated methods call: building a request, and the clients' call primitives."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
from collections.abc import AsyncIterator, Iterable, Iterator, Mapping
|
|
7
|
+
from typing import TYPE_CHECKING, Any, TypeVar
|
|
8
|
+
|
|
9
|
+
from google.protobuf.descriptor import FieldDescriptor
|
|
10
|
+
from google.protobuf.duration_pb2 import Duration
|
|
11
|
+
from google.protobuf.message import Message
|
|
12
|
+
from google.protobuf.timestamp_pb2 import Timestamp
|
|
13
|
+
|
|
14
|
+
from reef._rpc import Rpc
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from reef._stream import Stream
|
|
18
|
+
from reef.aio._stream import AsyncStream
|
|
19
|
+
|
|
20
|
+
Req = TypeVar("Req", bound=Message)
|
|
21
|
+
Resp = TypeVar("Resp", bound=Message)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def method_name(rpc: Rpc[Any, Any]) -> str:
|
|
25
|
+
"""The RPC's method name on the clients."""
|
|
26
|
+
out = []
|
|
27
|
+
for index, char in enumerate(rpc.name):
|
|
28
|
+
if char.isupper() and index:
|
|
29
|
+
out.append("_")
|
|
30
|
+
out.append(char.lower())
|
|
31
|
+
return "".join(out)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build(rpc: Rpc[Req, Any], request: Req | None, fields: Mapping[str, Any]) -> Req:
|
|
35
|
+
"""The request: ``request`` as given, or a new one with the keyword fields that were given.
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
TypeError: Both a request and keyword fields, or a request of the wrong type.
|
|
39
|
+
ValueError: An enum given by a name it does not have.
|
|
40
|
+
"""
|
|
41
|
+
given = {name: value for name, value in fields.items() if value is not None}
|
|
42
|
+
if request is not None:
|
|
43
|
+
if given:
|
|
44
|
+
raise TypeError(
|
|
45
|
+
f"{method_name(rpc)}() takes a request or keyword fields, not both "
|
|
46
|
+
f"(got a request and {', '.join(sorted(given))})"
|
|
47
|
+
)
|
|
48
|
+
if not isinstance(request, rpc.request):
|
|
49
|
+
raise TypeError(
|
|
50
|
+
f"{method_name(rpc)}() takes a {rpc.request.__name__}, "
|
|
51
|
+
f"not a {type(request).__name__}"
|
|
52
|
+
)
|
|
53
|
+
return request
|
|
54
|
+
message = rpc.request()
|
|
55
|
+
descriptor = message.DESCRIPTOR
|
|
56
|
+
for name, value in given.items():
|
|
57
|
+
_set(message, descriptor.fields_by_name[name], value)
|
|
58
|
+
return message
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _is_map(field: Any) -> bool:
|
|
62
|
+
return (
|
|
63
|
+
field.type == FieldDescriptor.TYPE_MESSAGE
|
|
64
|
+
and field.message_type is not None
|
|
65
|
+
and field.message_type.GetOptions().map_entry
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _convert(field: Any, value: Any) -> Any:
|
|
70
|
+
"""One value as the message field takes it."""
|
|
71
|
+
if field.type == FieldDescriptor.TYPE_ENUM and isinstance(value, str):
|
|
72
|
+
enum = field.enum_type
|
|
73
|
+
found = enum.values_by_name.get(value)
|
|
74
|
+
if found is None:
|
|
75
|
+
names = ", ".join(enum.values_by_name)
|
|
76
|
+
raise ValueError(
|
|
77
|
+
f"{field.name}: {value!r} is not one of {enum.name}'s values ({names})"
|
|
78
|
+
)
|
|
79
|
+
return found.number
|
|
80
|
+
if field.type == FieldDescriptor.TYPE_MESSAGE:
|
|
81
|
+
full = field.message_type.full_name
|
|
82
|
+
if full == "google.protobuf.Timestamp" and isinstance(value, dt.datetime):
|
|
83
|
+
stamp = Timestamp()
|
|
84
|
+
stamp.FromDatetime(value)
|
|
85
|
+
return stamp
|
|
86
|
+
if full == "google.protobuf.Duration" and isinstance(value, dt.timedelta):
|
|
87
|
+
duration = Duration()
|
|
88
|
+
duration.FromTimedelta(value)
|
|
89
|
+
return duration
|
|
90
|
+
return value
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _set(message: Message, field: Any, value: Any) -> None:
|
|
94
|
+
name = field.name
|
|
95
|
+
if _is_map(field):
|
|
96
|
+
target = getattr(message, name)
|
|
97
|
+
value_field = field.message_type.fields_by_name["value"]
|
|
98
|
+
for key, item in dict(value).items():
|
|
99
|
+
converted = _convert(value_field, item)
|
|
100
|
+
if value_field.type == FieldDescriptor.TYPE_MESSAGE:
|
|
101
|
+
target[key].CopyFrom(converted)
|
|
102
|
+
else:
|
|
103
|
+
target[key] = converted
|
|
104
|
+
return
|
|
105
|
+
if field.is_repeated:
|
|
106
|
+
if isinstance(value, (str, bytes)):
|
|
107
|
+
raise TypeError(f"{name} takes a list, not a single {type(value).__name__}")
|
|
108
|
+
getattr(message, name).extend(_convert(field, item) for item in value)
|
|
109
|
+
return
|
|
110
|
+
converted = _convert(field, value)
|
|
111
|
+
if field.type == FieldDescriptor.TYPE_MESSAGE:
|
|
112
|
+
target = getattr(message, name)
|
|
113
|
+
target.SetInParent()
|
|
114
|
+
target.CopyFrom(converted)
|
|
115
|
+
else:
|
|
116
|
+
setattr(message, name, converted)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class SyncCalls:
|
|
120
|
+
"""The sync client's call primitives, which the generated methods use."""
|
|
121
|
+
|
|
122
|
+
def _unary(self, rpc: Rpc[Req, Resp], request: Req) -> Resp:
|
|
123
|
+
raise NotImplementedError
|
|
124
|
+
|
|
125
|
+
def _stream(self, rpc: Rpc[Req, Resp], request: Req) -> Stream[Resp]:
|
|
126
|
+
raise NotImplementedError
|
|
127
|
+
|
|
128
|
+
def _pages(self, rpc: Rpc[Req, Resp], request: Req) -> Iterator[Resp]:
|
|
129
|
+
"""Every page, lazily: the same request with each ``next_page_token``."""
|
|
130
|
+
page_request = rpc.request()
|
|
131
|
+
page_request.CopyFrom(request)
|
|
132
|
+
while True:
|
|
133
|
+
page = self._unary(rpc, page_request)
|
|
134
|
+
yield page
|
|
135
|
+
token: str = page.next_page_token # type: ignore[attr-defined]
|
|
136
|
+
if not token:
|
|
137
|
+
return
|
|
138
|
+
page_request.page_token = token # type: ignore[attr-defined]
|
|
139
|
+
|
|
140
|
+
def _items(self, rpc: Rpc[Req, Resp], request: Req, field: str) -> Iterator[Any]:
|
|
141
|
+
"""Every page's ``field``, item by item."""
|
|
142
|
+
for page in self._pages(rpc, request):
|
|
143
|
+
yield from getattr(page, field)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class AsyncCalls:
|
|
147
|
+
"""The asyncio client's call primitives, which the generated methods use."""
|
|
148
|
+
|
|
149
|
+
async def _unary(self, rpc: Rpc[Req, Resp], request: Req) -> Resp:
|
|
150
|
+
raise NotImplementedError
|
|
151
|
+
|
|
152
|
+
def _stream(self, rpc: Rpc[Req, Resp], request: Req) -> AsyncStream[Resp]:
|
|
153
|
+
raise NotImplementedError
|
|
154
|
+
|
|
155
|
+
async def _pages(self, rpc: Rpc[Req, Resp], request: Req) -> AsyncIterator[Resp]:
|
|
156
|
+
"""Every page, lazily: the same request with each ``next_page_token``."""
|
|
157
|
+
page_request = rpc.request()
|
|
158
|
+
page_request.CopyFrom(request)
|
|
159
|
+
while True:
|
|
160
|
+
page = await self._unary(rpc, page_request)
|
|
161
|
+
yield page
|
|
162
|
+
token: str = page.next_page_token # type: ignore[attr-defined]
|
|
163
|
+
if not token:
|
|
164
|
+
return
|
|
165
|
+
page_request.page_token = token # type: ignore[attr-defined]
|
|
166
|
+
|
|
167
|
+
async def _items(self, rpc: Rpc[Req, Resp], request: Req, field: str) -> AsyncIterator[Any]:
|
|
168
|
+
"""Every page's ``field``, item by item."""
|
|
169
|
+
async for page in self._pages(rpc, request):
|
|
170
|
+
for item in getattr(page, field):
|
|
171
|
+
yield item
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
__all__ = ["AsyncCalls", "Iterable", "SyncCalls", "build"]
|
reef/_channel.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""How a client reaches the daemon: its socket, or a remote listener (R2.08), as grpc channels.
|
|
2
|
+
|
|
3
|
+
The socket's channel sets ``:authority`` to ``localhost``: grpc's default, the percent-encoded
|
|
4
|
+
socket path, is refused by the daemon's HTTP/2 server. A remote daemon is checked before grpc
|
|
5
|
+
connects, by a handshake of the SDK's own (``_tls``): against the pin, or against the same roots
|
|
6
|
+
grpc will trust. grpc then trusts only that.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import datetime as dt
|
|
12
|
+
import os
|
|
13
|
+
import ssl
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import grpc
|
|
18
|
+
|
|
19
|
+
from reef import _tls
|
|
20
|
+
from reef._remote import RemoteTarget
|
|
21
|
+
from reef.errors import RemoteConnectError, RemoteTlsError
|
|
22
|
+
|
|
23
|
+
MAX_MESSAGE_BYTES = 4 << 20
|
|
24
|
+
"""The largest message a daemon decodes or sends: tonic's default, which the daemon keeps."""
|
|
25
|
+
|
|
26
|
+
KEEPALIVE_MS = 30_000
|
|
27
|
+
|
|
28
|
+
ROOTS_ENV = "GRPC_DEFAULT_SSL_ROOTS_FILE_PATH"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Spec:
|
|
33
|
+
"""Everything needed to open a channel to one daemon."""
|
|
34
|
+
|
|
35
|
+
target: str
|
|
36
|
+
"""grpc's target: ``unix:<path>`` or ``<host>:<port>``."""
|
|
37
|
+
label: str
|
|
38
|
+
"""How errors name it: the socket path, or the remote's origin."""
|
|
39
|
+
options: list[tuple[str, Any]]
|
|
40
|
+
credentials: grpc.ChannelCredentials | None = None
|
|
41
|
+
remote: RemoteTarget | None = None
|
|
42
|
+
leaf: _tls.Leaf | None = field(default=None, repr=False)
|
|
43
|
+
"""The pinned remote's certificate grpc trusts."""
|
|
44
|
+
root_certificates: bytes | None = field(default=None, repr=False)
|
|
45
|
+
|
|
46
|
+
def open(self) -> grpc.Channel:
|
|
47
|
+
"""A sync channel."""
|
|
48
|
+
if self.credentials is None:
|
|
49
|
+
return grpc.insecure_channel(self.target, options=self.options)
|
|
50
|
+
return grpc.secure_channel(self.target, self.credentials, options=self.options)
|
|
51
|
+
|
|
52
|
+
def open_aio(self) -> grpc.aio.Channel:
|
|
53
|
+
"""An asyncio channel, on the running loop."""
|
|
54
|
+
if self.credentials is None:
|
|
55
|
+
return grpc.aio.insecure_channel(self.target, options=self.options)
|
|
56
|
+
return grpc.aio.secure_channel(self.target, self.credentials, options=self.options)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _limits() -> list[tuple[str, Any]]:
|
|
60
|
+
return [
|
|
61
|
+
("grpc.max_send_message_length", MAX_MESSAGE_BYTES),
|
|
62
|
+
("grpc.max_receive_message_length", MAX_MESSAGE_BYTES),
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def local(socket: str) -> Spec:
|
|
67
|
+
"""The daemon's socket."""
|
|
68
|
+
return Spec(
|
|
69
|
+
target=f"unix:{socket}",
|
|
70
|
+
label=socket,
|
|
71
|
+
options=[("grpc.default_authority", "localhost"), *_limits()],
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _grpc_roots() -> bytes:
|
|
76
|
+
"""The roots grpc trusts by default: its environment override, else its bundled file."""
|
|
77
|
+
path = os.environ.get(ROOTS_ENV) or os.path.join(
|
|
78
|
+
os.path.dirname(grpc.__file__), "_cython", "_credentials", "roots.pem"
|
|
79
|
+
)
|
|
80
|
+
with open(path, "rb") as file:
|
|
81
|
+
return file.read()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _presented(url_host: str, der: bytes) -> str:
|
|
85
|
+
del url_host
|
|
86
|
+
try:
|
|
87
|
+
return _tls.parse(der).fingerprint
|
|
88
|
+
except _tls.DerError:
|
|
89
|
+
return "an unparsable certificate"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def remote(
|
|
93
|
+
target: RemoteTarget,
|
|
94
|
+
*,
|
|
95
|
+
connect_timeout: float,
|
|
96
|
+
root_certificates: bytes | None = None,
|
|
97
|
+
) -> Spec:
|
|
98
|
+
"""A remote daemon, verified before anything is sent.
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
RemoteConnectError: Nothing answered.
|
|
102
|
+
RemoteTlsError: Its certificate was refused, or the handshake failed.
|
|
103
|
+
"""
|
|
104
|
+
url = target.url
|
|
105
|
+
origin = url.origin
|
|
106
|
+
try:
|
|
107
|
+
der = _tls.fetch_leaf(url, connect_timeout)
|
|
108
|
+
except ssl.SSLError as error:
|
|
109
|
+
raise RemoteTlsError(origin, "handshake", reason=str(error)) from error
|
|
110
|
+
except OSError as error:
|
|
111
|
+
raise RemoteConnectError(origin, str(error)) from error
|
|
112
|
+
options: list[tuple[str, Any]] = [
|
|
113
|
+
("grpc.default_authority", url.authority),
|
|
114
|
+
("grpc.keepalive_time_ms", KEEPALIVE_MS),
|
|
115
|
+
("grpc.keepalive_permit_without_calls", 1),
|
|
116
|
+
("grpc.http2.max_pings_without_data", 0),
|
|
117
|
+
*_limits(),
|
|
118
|
+
]
|
|
119
|
+
call_credentials = grpc.access_token_call_credentials(target.token)
|
|
120
|
+
if target.fingerprint:
|
|
121
|
+
try:
|
|
122
|
+
leaf = _tls.parse(der)
|
|
123
|
+
except _tls.DerError as error:
|
|
124
|
+
raise RemoteTlsError(origin, "handshake", reason=f"its certificate: {error}") from error
|
|
125
|
+
if leaf.fingerprint != target.fingerprint:
|
|
126
|
+
raise RemoteTlsError(
|
|
127
|
+
origin,
|
|
128
|
+
"pin_mismatch",
|
|
129
|
+
presented=leaf.fingerprint,
|
|
130
|
+
expected=target.fingerprint,
|
|
131
|
+
)
|
|
132
|
+
check_dates(leaf, origin, dt.datetime.now(dt.UTC))
|
|
133
|
+
# grpc checks the certificate's names, and a call's :authority against them, even with
|
|
134
|
+
# the key pinned. The pin is what authenticates the daemon, so when the URL's host is not
|
|
135
|
+
# one of the names (an IP address the certificate does not list, say), the SDK names one
|
|
136
|
+
# that is, as the daemon's own Host check accepts.
|
|
137
|
+
name = _tls.target_name(leaf, url.host)
|
|
138
|
+
if name is not None:
|
|
139
|
+
options.append(("grpc.ssl_target_name_override", name))
|
|
140
|
+
if name != url.host:
|
|
141
|
+
options[0] = ("grpc.default_authority", _authority(name, url.port))
|
|
142
|
+
credentials = grpc.composite_channel_credentials(
|
|
143
|
+
grpc.ssl_channel_credentials(root_certificates=leaf.pem), call_credentials
|
|
144
|
+
)
|
|
145
|
+
return Spec(
|
|
146
|
+
target=url.authority,
|
|
147
|
+
label=origin,
|
|
148
|
+
options=options,
|
|
149
|
+
credentials=credentials,
|
|
150
|
+
remote=target,
|
|
151
|
+
leaf=leaf,
|
|
152
|
+
)
|
|
153
|
+
roots = root_certificates or _grpc_roots()
|
|
154
|
+
context = ssl.create_default_context(cadata=roots.decode("ascii", "replace"))
|
|
155
|
+
try:
|
|
156
|
+
context.check_hostname = True
|
|
157
|
+
import socket
|
|
158
|
+
|
|
159
|
+
with (
|
|
160
|
+
socket.create_connection((url.host, url.port), timeout=connect_timeout) as raw,
|
|
161
|
+
context.wrap_socket(raw, server_hostname=url.host),
|
|
162
|
+
):
|
|
163
|
+
pass
|
|
164
|
+
except ssl.SSLCertVerificationError as error:
|
|
165
|
+
raise RemoteTlsError(
|
|
166
|
+
origin,
|
|
167
|
+
"untrusted",
|
|
168
|
+
presented=_presented(url.host, der),
|
|
169
|
+
reason=error.verify_message or str(error),
|
|
170
|
+
) from error
|
|
171
|
+
except ssl.SSLError as error:
|
|
172
|
+
raise RemoteTlsError(origin, "handshake", reason=str(error)) from error
|
|
173
|
+
except OSError as error:
|
|
174
|
+
raise RemoteConnectError(origin, str(error)) from error
|
|
175
|
+
credentials = grpc.composite_channel_credentials(
|
|
176
|
+
grpc.ssl_channel_credentials(root_certificates=root_certificates), call_credentials
|
|
177
|
+
)
|
|
178
|
+
return Spec(
|
|
179
|
+
target=url.authority,
|
|
180
|
+
label=origin,
|
|
181
|
+
options=options,
|
|
182
|
+
credentials=credentials,
|
|
183
|
+
remote=target,
|
|
184
|
+
root_certificates=root_certificates,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _authority(host: str, port: int) -> str:
|
|
189
|
+
return f"[{host}]:{port}" if ":" in host else f"{host}:{port}"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def check_dates(leaf: _tls.Leaf, origin: str, now: dt.datetime) -> None:
|
|
193
|
+
"""Raises :class:`RemoteTlsError` (``handshake``) for a pinned certificate outside its dates.
|
|
194
|
+
|
|
195
|
+
grpc checks the dates even with the key pinned, which the pin does not need; a lapsed
|
|
196
|
+
certificate would otherwise fail inside grpc as a connection that never comes up.
|
|
197
|
+
"""
|
|
198
|
+
if leaf.not_after is not None and now > leaf.not_after:
|
|
199
|
+
raise RemoteTlsError(
|
|
200
|
+
origin,
|
|
201
|
+
"handshake",
|
|
202
|
+
reason=f"the daemon's certificate expired at {leaf.not_after:%Y-%m-%d %H:%M} UTC",
|
|
203
|
+
hint=(
|
|
204
|
+
"The daemon's certificate has lapsed. A generated one is renewed when the "
|
|
205
|
+
"daemon starts: restart it on its machine. Renew a supplied one "
|
|
206
|
+
"(`[remote] tls_cert`)."
|
|
207
|
+
),
|
|
208
|
+
)
|
|
209
|
+
if leaf.not_before is not None and now < leaf.not_before:
|
|
210
|
+
raise RemoteTlsError(
|
|
211
|
+
origin,
|
|
212
|
+
"handshake",
|
|
213
|
+
reason=f"the daemon's certificate is valid only from "
|
|
214
|
+
f"{leaf.not_before:%Y-%m-%d %H:%M} UTC; check this machine's clock",
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def repinned(spec: Spec, connect_timeout: float) -> Spec | None:
|
|
219
|
+
"""For a pinned remote whose connection failed: a new spec if the daemon now presents a new
|
|
220
|
+
certificate for the pinned key (a renewal), else None.
|
|
221
|
+
|
|
222
|
+
Raises:
|
|
223
|
+
RemoteTlsError: It now presents another key.
|
|
224
|
+
"""
|
|
225
|
+
if spec.remote is None or spec.leaf is None or not spec.remote.fingerprint:
|
|
226
|
+
return None
|
|
227
|
+
try:
|
|
228
|
+
der = _tls.fetch_leaf(spec.remote.url, connect_timeout)
|
|
229
|
+
except (OSError, ssl.SSLError):
|
|
230
|
+
return None
|
|
231
|
+
if der == spec.leaf.der:
|
|
232
|
+
return None
|
|
233
|
+
return remote(spec.remote, connect_timeout=connect_timeout)
|
reef/_connect.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Settling what ``connect()`` reaches, shared by the sync and asyncio clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import grpc
|
|
10
|
+
|
|
11
|
+
from reef import _channel, _paths, _remote
|
|
12
|
+
from reef._build import CARGO_VERSION
|
|
13
|
+
from reef.errors import (
|
|
14
|
+
DETAIL_TYPE_URL,
|
|
15
|
+
DaemonNotRunningError,
|
|
16
|
+
IncompatibleError,
|
|
17
|
+
ReefError,
|
|
18
|
+
RemoteConnectError,
|
|
19
|
+
RemoteMisdirectedError,
|
|
20
|
+
detail_of,
|
|
21
|
+
from_status,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
V0_STATUS = "/reef.daemon.v0.Daemon/Status"
|
|
25
|
+
DAEMON_STATUS = "/reef.api.v1.DaemonService/GetDaemonStatus"
|
|
26
|
+
UNSERVED_REASONS = ("UNKNOWN_METHOD", "NOT_IMPLEMENTED")
|
|
27
|
+
# The listener's HTTP 421, as grpcio reports it ("Received http2 header with status: 421"), or as
|
|
28
|
+
# tonic does ("... HTTP status code 421").
|
|
29
|
+
MISDIRECTED = re.compile(r"(header with status: |HTTP status code )421$")
|
|
30
|
+
|
|
31
|
+
__all__ = ["DETAIL_TYPE_URL", "spec_for"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def spec_for(
|
|
35
|
+
*,
|
|
36
|
+
socket: str | os.PathLike[str] | None,
|
|
37
|
+
remote: str | None,
|
|
38
|
+
token: str | None,
|
|
39
|
+
fingerprint: str | None,
|
|
40
|
+
state_dir: str | os.PathLike[str] | None,
|
|
41
|
+
config: str | os.PathLike[str] | None,
|
|
42
|
+
connect_timeout: float,
|
|
43
|
+
root_certificates: bytes | None,
|
|
44
|
+
) -> _channel.Spec:
|
|
45
|
+
"""The socket, or the remote, that ``connect()``'s arguments and the environment name.
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
TypeError: Both ``socket`` and ``remote``.
|
|
49
|
+
ConfigError: The paths, or the remote's profile, cannot be settled.
|
|
50
|
+
RemoteConnectError: Nothing answered at the remote.
|
|
51
|
+
RemoteTlsError: The remote's certificate was refused.
|
|
52
|
+
"""
|
|
53
|
+
if socket is not None and remote is not None:
|
|
54
|
+
raise TypeError("connect() takes a socket or a remote, not both")
|
|
55
|
+
if socket is None and remote is None:
|
|
56
|
+
from_env = os.environ.get(_remote.REMOTE_ENV, "")
|
|
57
|
+
remote = from_env.strip() or None
|
|
58
|
+
if remote is not None:
|
|
59
|
+
state = _paths.settle_state_dir(state_dir=state_dir)
|
|
60
|
+
target = _remote.choose(remote, state_dir=state, token=token, fingerprint=fingerprint)
|
|
61
|
+
return _channel.remote(
|
|
62
|
+
target, connect_timeout=connect_timeout, root_certificates=root_certificates
|
|
63
|
+
)
|
|
64
|
+
settled = _paths.settle(socket=socket, state_dir=state_dir, config=config)
|
|
65
|
+
return _channel.local(settled.socket)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def connect_error(spec: _channel.Spec, failed: bool, timeout: float) -> ReefError:
|
|
69
|
+
"""Why ``connect()`` could not reach the daemon: the connection failed (``failed``), or
|
|
70
|
+
nothing settled within ``timeout``.
|
|
71
|
+
"""
|
|
72
|
+
cause = "the connection failed" if failed else f"no connection within {timeout:g} s"
|
|
73
|
+
if spec.remote is not None:
|
|
74
|
+
return RemoteConnectError(spec.label, cause)
|
|
75
|
+
return DaemonNotRunningError(spec.label, cause)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def is_unserved(error: grpc.RpcError) -> bool:
|
|
79
|
+
"""Whether an error may say the daemon cannot serve the method: a bare ``UNIMPLEMENTED``,
|
|
80
|
+
or one whose reason is ``UNKNOWN_METHOD`` or ``NOT_IMPLEMENTED``.
|
|
81
|
+
"""
|
|
82
|
+
if error.code() != grpc.StatusCode.UNIMPLEMENTED:
|
|
83
|
+
return False
|
|
84
|
+
detail = detail_of(error.trailing_metadata())
|
|
85
|
+
return detail is None or detail.reason in UNSERVED_REASONS
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def misdirected(spec: _channel.Spec, error: grpc.RpcError) -> ReefError | None:
|
|
89
|
+
"""A remote daemon's HTTP 421 (a host name it does not serve), as its error."""
|
|
90
|
+
if (
|
|
91
|
+
spec.remote is not None
|
|
92
|
+
and error.code() == grpc.StatusCode.UNKNOWN
|
|
93
|
+
and MISDIRECTED.search(error.details() or "")
|
|
94
|
+
and detail_of(error.trailing_metadata()) is None
|
|
95
|
+
):
|
|
96
|
+
return RemoteMisdirectedError(spec.remote.url.origin, spec.remote.url.host)
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def needs_repin(spec: _channel.Spec, error: grpc.RpcError) -> bool:
|
|
101
|
+
"""Whether a failure on a pinned channel may be a renewed certificate."""
|
|
102
|
+
return (
|
|
103
|
+
spec.leaf is not None
|
|
104
|
+
and error.code() == grpc.StatusCode.UNAVAILABLE
|
|
105
|
+
and detail_of(error.trailing_metadata()) is None
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def incompatible(
|
|
110
|
+
spec: _channel.Spec,
|
|
111
|
+
method: str,
|
|
112
|
+
error: grpc.RpcError,
|
|
113
|
+
v1: Any | None,
|
|
114
|
+
v0: Any | None,
|
|
115
|
+
) -> ReefError:
|
|
116
|
+
"""What a daemon that could not serve ``method`` is, from its answers to the two status
|
|
117
|
+
calls (``v1``: ``GetDaemonStatusResponse`` or None; ``v0``: 0.1's ``StatusResponse``).
|
|
118
|
+
"""
|
|
119
|
+
original = from_status(error.code(), error.details() or "", error.trailing_metadata(), method)
|
|
120
|
+
if v1 is not None:
|
|
121
|
+
detail = detail_of(error.trailing_metadata())
|
|
122
|
+
same_version = v1.version == CARGO_VERSION
|
|
123
|
+
if same_version and detail is not None and detail.reason == "NOT_IMPLEMENTED":
|
|
124
|
+
return original
|
|
125
|
+
return IncompatibleError(
|
|
126
|
+
spec.label,
|
|
127
|
+
method,
|
|
128
|
+
daemon_version=v1.version or "unknown",
|
|
129
|
+
daemon_api="v1",
|
|
130
|
+
daemon_revision=v1.api_revision,
|
|
131
|
+
active_workspaces=v1.active_workspace_count,
|
|
132
|
+
sdk_version=CARGO_VERSION,
|
|
133
|
+
)
|
|
134
|
+
if v0 is not None:
|
|
135
|
+
return IncompatibleError(
|
|
136
|
+
spec.label,
|
|
137
|
+
method,
|
|
138
|
+
daemon_version=v0.version or "unknown",
|
|
139
|
+
daemon_api="v0",
|
|
140
|
+
active_workspaces=v0.active_workspaces,
|
|
141
|
+
sdk_version=CARGO_VERSION,
|
|
142
|
+
)
|
|
143
|
+
return original
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def require(spec: _channel.Spec, revision: int, status: Any) -> None:
|
|
147
|
+
"""Raises :class:`IncompatibleError` when the daemon's API revision is below ``revision``."""
|
|
148
|
+
if status.api_revision >= revision:
|
|
149
|
+
return
|
|
150
|
+
raise IncompatibleError(
|
|
151
|
+
spec.label,
|
|
152
|
+
f"API revision {revision}",
|
|
153
|
+
daemon_version=status.version or "unknown",
|
|
154
|
+
daemon_api="v1",
|
|
155
|
+
daemon_revision=status.api_revision,
|
|
156
|
+
active_workspaces=status.active_workspace_count,
|
|
157
|
+
sdk_version=CARGO_VERSION,
|
|
158
|
+
detail=(
|
|
159
|
+
f"the daemon at {spec.label} is reef {status.version} at API revision "
|
|
160
|
+
f"{status.api_revision}; this needs revision {revision}"
|
|
161
|
+
),
|
|
162
|
+
)
|
reef/_env.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Process settings applied before grpc is imported.
|
|
2
|
+
|
|
3
|
+
grpc's fork support logs two lines at INFO from every ``subprocess`` call made while a channel is
|
|
4
|
+
open, which an agent makes all the time, and a child process cannot use the parent's channels
|
|
5
|
+
anyway (create clients after forking). So the SDK turns it off unless the program chose:
|
|
6
|
+
``GRPC_ENABLE_FORK_SUPPORT`` set beforehand wins, and grpc imported before reef keeps its own.
|
|
7
|
+
grpc reads it when it starts, so it stays set, and the program's child processes inherit it.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
os.environ.setdefault("GRPC_ENABLE_FORK_SUPPORT", "false")
|