androidctl 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.
- androidctl/__init__.py +5 -0
- androidctl/__main__.py +4 -0
- androidctl/_version.py +1 -0
- androidctl/app.py +73 -0
- androidctl/cli_options.py +27 -0
- androidctl/command_payloads.py +264 -0
- androidctl/command_views.py +157 -0
- androidctl/commands/__init__.py +1 -0
- androidctl/commands/actions.py +236 -0
- androidctl/commands/adb_wireless.py +157 -0
- androidctl/commands/close.py +30 -0
- androidctl/commands/connect.py +69 -0
- androidctl/commands/execute.py +179 -0
- androidctl/commands/list_apps.py +26 -0
- androidctl/commands/observe.py +26 -0
- androidctl/commands/open.py +41 -0
- androidctl/commands/plumbing.py +58 -0
- androidctl/commands/run_pipeline.py +307 -0
- androidctl/commands/screenshot.py +29 -0
- androidctl/commands/setup.py +301 -0
- androidctl/commands/wait.py +60 -0
- androidctl/daemon/__init__.py +1 -0
- androidctl/daemon/client.py +348 -0
- androidctl/daemon/discovery.py +190 -0
- androidctl/daemon/launcher.py +26 -0
- androidctl/daemon/owner.py +349 -0
- androidctl/errors/__init__.py +1 -0
- androidctl/errors/mapping.py +149 -0
- androidctl/errors/models.py +16 -0
- androidctl/exit_codes.py +8 -0
- androidctl/output.py +147 -0
- androidctl/parsing/__init__.py +1 -0
- androidctl/parsing/duration.py +17 -0
- androidctl/parsing/open_target.py +51 -0
- androidctl/parsing/refs.py +12 -0
- androidctl/parsing/screen_id.py +10 -0
- androidctl/parsing/wait.py +70 -0
- androidctl/renderers/__init__.py +110 -0
- androidctl/renderers/_paths.py +109 -0
- androidctl/renderers/xml.py +234 -0
- androidctl/renderers/xml_projection.py +732 -0
- androidctl/resources/__init__.py +1 -0
- androidctl/resources/androidctl-agent-0.1.0-release.apk +0 -0
- androidctl/setup/__init__.py +1 -0
- androidctl/setup/accessibility.py +159 -0
- androidctl/setup/adb.py +586 -0
- androidctl/setup/apk_resource.py +29 -0
- androidctl/setup/pairing.py +70 -0
- androidctl/setup/verify.py +175 -0
- androidctl/workspace/__init__.py +3 -0
- androidctl/workspace/resolve.py +27 -0
- androidctl-0.1.0.dist-info/METADATA +217 -0
- androidctl-0.1.0.dist-info/RECORD +187 -0
- androidctl-0.1.0.dist-info/WHEEL +5 -0
- androidctl-0.1.0.dist-info/entry_points.txt +3 -0
- androidctl-0.1.0.dist-info/licenses/LICENSE +674 -0
- androidctl-0.1.0.dist-info/top_level.txt +3 -0
- androidctl_contracts/__init__.py +55 -0
- androidctl_contracts/_version.py +1 -0
- androidctl_contracts/_wire_helpers.py +31 -0
- androidctl_contracts/base.py +142 -0
- androidctl_contracts/command_catalog.py +414 -0
- androidctl_contracts/command_results.py +630 -0
- androidctl_contracts/daemon_api.py +335 -0
- androidctl_contracts/errors.py +44 -0
- androidctl_contracts/paths.py +5 -0
- androidctl_contracts/public_screen.py +579 -0
- androidctl_contracts/user_state.py +23 -0
- androidctl_contracts/vocabulary.py +82 -0
- androidctld/__init__.py +5 -0
- androidctld/__main__.py +63 -0
- androidctld/_version.py +1 -0
- androidctld/actions/__init__.py +1 -0
- androidctld/actions/action_target.py +142 -0
- androidctld/actions/capabilities.py +539 -0
- androidctld/actions/executor.py +894 -0
- androidctld/actions/focus_confirmation.py +177 -0
- androidctld/actions/focused_input_admissibility.py +120 -0
- androidctld/actions/fresh_current.py +176 -0
- androidctld/actions/postconditions.py +473 -0
- androidctld/actions/repair.py +101 -0
- androidctld/actions/request_builder.py +204 -0
- androidctld/actions/settle.py +146 -0
- androidctld/actions/submit_confirmation.py +211 -0
- androidctld/actions/submit_routing.py +311 -0
- androidctld/actions/type_confirmation.py +257 -0
- androidctld/app_targets.py +71 -0
- androidctld/artifacts/__init__.py +1 -0
- androidctld/artifacts/models.py +26 -0
- androidctld/artifacts/screen_lookup.py +241 -0
- androidctld/artifacts/screen_payloads.py +109 -0
- androidctld/artifacts/writer.py +286 -0
- androidctld/auth/__init__.py +1 -0
- androidctld/auth/active_registry.py +266 -0
- androidctld/auth/secret_files.py +52 -0
- androidctld/auth/token_store.py +59 -0
- androidctld/commands/__init__.py +1 -0
- androidctld/commands/assembly.py +231 -0
- androidctld/commands/command_models.py +254 -0
- androidctld/commands/dispatch.py +99 -0
- androidctld/commands/executor.py +31 -0
- androidctld/commands/from_boundary.py +175 -0
- androidctld/commands/handlers/__init__.py +15 -0
- androidctld/commands/handlers/action.py +439 -0
- androidctld/commands/handlers/connect.py +94 -0
- androidctld/commands/handlers/list_apps.py +215 -0
- androidctld/commands/handlers/observe.py +121 -0
- androidctld/commands/handlers/screenshot.py +105 -0
- androidctld/commands/handlers/wait.py +286 -0
- androidctld/commands/models.py +65 -0
- androidctld/commands/open_targets.py +56 -0
- androidctld/commands/orchestration.py +353 -0
- androidctld/commands/registry.py +116 -0
- androidctld/commands/result_builders.py +40 -0
- androidctld/commands/result_models.py +555 -0
- androidctld/commands/results.py +108 -0
- androidctld/commands/semantic_command_names.py +17 -0
- androidctld/commands/semantic_error_mapping.py +93 -0
- androidctld/commands/semantic_truth.py +135 -0
- androidctld/commands/service.py +67 -0
- androidctld/config.py +75 -0
- androidctld/daemon/__init__.py +1 -0
- androidctld/daemon/active_slot.py +326 -0
- androidctld/daemon/envelope.py +30 -0
- androidctld/daemon/http_host.py +123 -0
- androidctld/daemon/ingress.py +112 -0
- androidctld/daemon/ownership_probe.py +204 -0
- androidctld/daemon/server.py +286 -0
- androidctld/daemon/service.py +99 -0
- androidctld/device/__init__.py +1 -0
- androidctld/device/action_models.py +154 -0
- androidctld/device/action_serialization.py +121 -0
- androidctld/device/adapters.py +220 -0
- androidctld/device/bootstrap.py +153 -0
- androidctld/device/connectors.py +231 -0
- androidctld/device/errors.py +100 -0
- androidctld/device/interfaces.py +58 -0
- androidctld/device/parsing.py +320 -0
- androidctld/device/rpc.py +483 -0
- androidctld/device/schema.py +114 -0
- androidctld/device/types.py +161 -0
- androidctld/errors/__init__.py +94 -0
- androidctld/logging/__init__.py +22 -0
- androidctld/observation.py +98 -0
- androidctld/protocol.py +53 -0
- androidctld/refs/__init__.py +1 -0
- androidctld/refs/models.py +54 -0
- androidctld/refs/repair.py +284 -0
- androidctld/refs/service.py +422 -0
- androidctld/rendering/__init__.py +1 -0
- androidctld/rendering/screen_xml.py +256 -0
- androidctld/runtime/__init__.py +21 -0
- androidctld/runtime/kernel.py +548 -0
- androidctld/runtime/lifecycle.py +19 -0
- androidctld/runtime/models.py +48 -0
- androidctld/runtime/screen_state.py +117 -0
- androidctld/runtime/state_repo.py +70 -0
- androidctld/runtime/store.py +76 -0
- androidctld/runtime_policy.py +127 -0
- androidctld/schema/__init__.py +5 -0
- androidctld/schema/base.py +132 -0
- androidctld/schema/core.py +35 -0
- androidctld/schema/daemon_api.py +108 -0
- androidctld/schema/persistence.py +161 -0
- androidctld/schema/persistence_io.py +41 -0
- androidctld/schema/validation_errors.py +309 -0
- androidctld/semantics/__init__.py +1 -0
- androidctld/semantics/compiler.py +610 -0
- androidctld/semantics/continuity.py +107 -0
- androidctld/semantics/labels.py +252 -0
- androidctld/semantics/models.py +25 -0
- androidctld/semantics/policy.py +23 -0
- androidctld/semantics/public_models.py +123 -0
- androidctld/semantics/registries.py +13 -0
- androidctld/semantics/submit_refs.py +417 -0
- androidctld/semantics/surface.py +254 -0
- androidctld/semantics/targets.py +167 -0
- androidctld/snapshots/__init__.py +1 -0
- androidctld/snapshots/models.py +219 -0
- androidctld/snapshots/refresh.py +273 -0
- androidctld/snapshots/schema.py +74 -0
- androidctld/snapshots/service.py +138 -0
- androidctld/text_equivalence.py +67 -0
- androidctld/waits/__init__.py +1 -0
- androidctld/waits/evaluators.py +216 -0
- androidctld/waits/loop.py +305 -0
- androidctld/waits/matcher.py +41 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""JSON envelope helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from androidctl_contracts.daemon_api import DaemonErrorEnvelope
|
|
8
|
+
from androidctl_contracts.errors import DaemonError as ContractDaemonError
|
|
9
|
+
from androidctl_contracts.errors import DaemonErrorCode as ContractDaemonErrorCode
|
|
10
|
+
from androidctld.errors import DaemonError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def success_envelope(result: dict[str, Any]) -> dict[str, Any]:
|
|
14
|
+
return {
|
|
15
|
+
"ok": True,
|
|
16
|
+
"result": result,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def error_envelope(error: DaemonError) -> dict[str, Any]:
|
|
21
|
+
try:
|
|
22
|
+
contract_error = error.to_contract_error()
|
|
23
|
+
except ValueError:
|
|
24
|
+
contract_error = ContractDaemonError(
|
|
25
|
+
code=ContractDaemonErrorCode.INTERNAL_COMMAND_FAILURE,
|
|
26
|
+
message="unexpected daemon failure",
|
|
27
|
+
retryable=False,
|
|
28
|
+
details={},
|
|
29
|
+
)
|
|
30
|
+
return DaemonErrorEnvelope(error=contract_error).model_dump()
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""HTTP host lifecycle and readiness probing for androidctld."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
11
|
+
from typing import Any, Protocol
|
|
12
|
+
from urllib.error import HTTPError, URLError
|
|
13
|
+
from urllib.request import OpenerDirector, ProxyHandler, Request, build_opener
|
|
14
|
+
|
|
15
|
+
from androidctl_contracts.daemon_api import (
|
|
16
|
+
OWNER_HEADER_NAME,
|
|
17
|
+
DaemonSuccessEnvelope,
|
|
18
|
+
HealthResult,
|
|
19
|
+
)
|
|
20
|
+
from androidctld import SERVICE_NAME
|
|
21
|
+
from androidctld.auth.active_registry import ActiveDaemonRecord
|
|
22
|
+
from androidctld.config import TOKEN_HEADER_NAME, DaemonConfig
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _Opener(Protocol):
|
|
26
|
+
def open(self, request: Request, timeout: float) -> Any: ...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DaemonHttpHost:
|
|
30
|
+
_READY_TIMEOUT_SECONDS = 2.0
|
|
31
|
+
_READY_POLL_INTERVAL_SECONDS = 0.05
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
*,
|
|
36
|
+
config: DaemonConfig,
|
|
37
|
+
logger: logging.Logger,
|
|
38
|
+
opener_factory: Callable[[], _Opener | OpenerDirector] | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
self._config = config
|
|
41
|
+
self._logger = logger
|
|
42
|
+
self._opener_factory: Callable[[], _Opener | OpenerDirector]
|
|
43
|
+
self._opener_factory = opener_factory or self._build_proxy_bypassing_opener
|
|
44
|
+
self._server: ThreadingHTTPServer | None = None
|
|
45
|
+
self._thread: threading.Thread | None = None
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def is_running(self) -> bool:
|
|
49
|
+
return self._server is not None
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def ready_poll_interval_seconds(self) -> float:
|
|
53
|
+
return self._READY_POLL_INTERVAL_SECONDS
|
|
54
|
+
|
|
55
|
+
def start(self, handler_class: type[BaseHTTPRequestHandler]) -> tuple[str, int]:
|
|
56
|
+
if self._server is not None:
|
|
57
|
+
raise RuntimeError("androidctld server is already running")
|
|
58
|
+
host = self._config.host
|
|
59
|
+
httpd = ThreadingHTTPServer((host, self._config.port), handler_class)
|
|
60
|
+
httpd.daemon_threads = True
|
|
61
|
+
try:
|
|
62
|
+
thread = threading.Thread(
|
|
63
|
+
target=httpd.serve_forever,
|
|
64
|
+
name="androidctld-http",
|
|
65
|
+
daemon=True,
|
|
66
|
+
)
|
|
67
|
+
thread.start()
|
|
68
|
+
except Exception:
|
|
69
|
+
httpd.server_close()
|
|
70
|
+
raise
|
|
71
|
+
self._server = httpd
|
|
72
|
+
self._thread = thread
|
|
73
|
+
_, port = httpd.server_address[:2]
|
|
74
|
+
return host, port
|
|
75
|
+
|
|
76
|
+
def wait_until_ready(
|
|
77
|
+
self,
|
|
78
|
+
*,
|
|
79
|
+
record: ActiveDaemonRecord,
|
|
80
|
+
owner_id: str | None = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
deadline = time.monotonic() + self._READY_TIMEOUT_SECONDS
|
|
83
|
+
health_url = f"http://{record.host}:{record.port}/health"
|
|
84
|
+
headers = {
|
|
85
|
+
TOKEN_HEADER_NAME: record.token,
|
|
86
|
+
OWNER_HEADER_NAME: owner_id or self._config.owner_id,
|
|
87
|
+
}
|
|
88
|
+
opener = self._opener_factory()
|
|
89
|
+
while True:
|
|
90
|
+
request = Request(health_url, method="POST", data=b"{}", headers=headers)
|
|
91
|
+
try:
|
|
92
|
+
with opener.open(
|
|
93
|
+
request, timeout=self._READY_POLL_INTERVAL_SECONDS
|
|
94
|
+
) as response:
|
|
95
|
+
payload = json.loads(response.read().decode("utf-8"))
|
|
96
|
+
envelope = DaemonSuccessEnvelope[HealthResult].model_validate(payload)
|
|
97
|
+
if envelope.result.service == SERVICE_NAME:
|
|
98
|
+
return
|
|
99
|
+
except HTTPError as error:
|
|
100
|
+
error.close()
|
|
101
|
+
except (OSError, URLError, ValueError, json.JSONDecodeError):
|
|
102
|
+
pass
|
|
103
|
+
if time.monotonic() >= deadline:
|
|
104
|
+
raise RuntimeError(
|
|
105
|
+
"timed out waiting for androidctld readiness health check"
|
|
106
|
+
)
|
|
107
|
+
time.sleep(self._READY_POLL_INTERVAL_SECONDS)
|
|
108
|
+
|
|
109
|
+
def stop(self) -> None:
|
|
110
|
+
server = self._server
|
|
111
|
+
thread = self._thread
|
|
112
|
+
self._server = None
|
|
113
|
+
self._thread = None
|
|
114
|
+
if server is None:
|
|
115
|
+
return
|
|
116
|
+
server.shutdown()
|
|
117
|
+
server.server_close()
|
|
118
|
+
if thread is not None:
|
|
119
|
+
thread.join(timeout=2.0)
|
|
120
|
+
|
|
121
|
+
@staticmethod
|
|
122
|
+
def _build_proxy_bypassing_opener() -> OpenerDirector:
|
|
123
|
+
return build_opener(ProxyHandler({}))
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Daemon ingress boundary for authenticated request dispatch."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Protocol
|
|
8
|
+
|
|
9
|
+
from androidctl_contracts.command_results import RetainedResultEnvelope
|
|
10
|
+
from androidctl_contracts.daemon_api import OWNER_HEADER_NAME
|
|
11
|
+
from androidctld.config import TOKEN_HEADER_NAME
|
|
12
|
+
from androidctld.errors import DaemonError, DaemonErrorCode, unauthorized
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DaemonDispatcher(Protocol):
|
|
16
|
+
def handle(
|
|
17
|
+
self,
|
|
18
|
+
method: str,
|
|
19
|
+
path: str,
|
|
20
|
+
headers: dict[str, str],
|
|
21
|
+
body: bytes,
|
|
22
|
+
) -> tuple[int, dict[str, Any]]: ...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class IngressResult:
|
|
27
|
+
status_code: int
|
|
28
|
+
payload: dict[str, Any]
|
|
29
|
+
shutdown_after_write: bool = False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DaemonIngress:
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
*,
|
|
36
|
+
token_provider: Callable[[], str | None],
|
|
37
|
+
owner_id_provider: Callable[[], str | None],
|
|
38
|
+
dispatcher: DaemonDispatcher,
|
|
39
|
+
) -> None:
|
|
40
|
+
self._token_provider = token_provider
|
|
41
|
+
self._owner_id_provider = owner_id_provider
|
|
42
|
+
self._dispatcher = dispatcher
|
|
43
|
+
|
|
44
|
+
def handle(
|
|
45
|
+
self,
|
|
46
|
+
*,
|
|
47
|
+
method: str,
|
|
48
|
+
path: str,
|
|
49
|
+
headers: dict[str, str],
|
|
50
|
+
body: bytes,
|
|
51
|
+
) -> IngressResult:
|
|
52
|
+
self._require_token(headers)
|
|
53
|
+
self._require_owner(headers)
|
|
54
|
+
status_code, payload = self._dispatcher.handle(
|
|
55
|
+
method=method,
|
|
56
|
+
path=path,
|
|
57
|
+
headers=headers,
|
|
58
|
+
body=body,
|
|
59
|
+
)
|
|
60
|
+
return IngressResult(
|
|
61
|
+
status_code=status_code,
|
|
62
|
+
payload=payload,
|
|
63
|
+
shutdown_after_write=_should_shutdown_after_write(
|
|
64
|
+
method=method,
|
|
65
|
+
path=path,
|
|
66
|
+
status_code=status_code,
|
|
67
|
+
payload=payload,
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def _require_token(self, headers: dict[str, str]) -> None:
|
|
72
|
+
expected = self._token_provider()
|
|
73
|
+
supplied = self._header_value(headers, TOKEN_HEADER_NAME)
|
|
74
|
+
if not expected or supplied != expected:
|
|
75
|
+
raise unauthorized()
|
|
76
|
+
|
|
77
|
+
def _require_owner(self, headers: dict[str, str]) -> None:
|
|
78
|
+
owner_id = self._owner_id_provider()
|
|
79
|
+
if not owner_id:
|
|
80
|
+
return
|
|
81
|
+
supplied = self._header_value(headers, OWNER_HEADER_NAME)
|
|
82
|
+
if supplied == owner_id:
|
|
83
|
+
return
|
|
84
|
+
raise DaemonError(
|
|
85
|
+
code=DaemonErrorCode.WORKSPACE_BUSY,
|
|
86
|
+
message="workspace daemon is owned by a different shell or agent",
|
|
87
|
+
retryable=False,
|
|
88
|
+
details={"ownerId": owner_id},
|
|
89
|
+
http_status=200,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def _header_value(self, headers: dict[str, str], header_name: str) -> str | None:
|
|
93
|
+
for key, value in headers.items():
|
|
94
|
+
if key.lower() == header_name.lower():
|
|
95
|
+
return value.strip()
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _should_shutdown_after_write(
|
|
100
|
+
*,
|
|
101
|
+
method: str,
|
|
102
|
+
path: str,
|
|
103
|
+
status_code: int,
|
|
104
|
+
payload: dict[str, Any],
|
|
105
|
+
) -> bool:
|
|
106
|
+
if method != "POST" or path != "/runtime/close" or status_code != 200:
|
|
107
|
+
return False
|
|
108
|
+
try:
|
|
109
|
+
result = RetainedResultEnvelope.model_validate(payload)
|
|
110
|
+
except ValueError:
|
|
111
|
+
return False
|
|
112
|
+
return result.ok is True and result.command == "close"
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Listener health evidence for daemon ownership records."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Callable, Iterable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import Any, Protocol
|
|
10
|
+
from urllib.error import HTTPError, URLError
|
|
11
|
+
from urllib.request import OpenerDirector, ProxyHandler, Request, build_opener
|
|
12
|
+
|
|
13
|
+
from androidctl_contracts.daemon_api import (
|
|
14
|
+
OWNER_HEADER_NAME,
|
|
15
|
+
DaemonErrorEnvelope,
|
|
16
|
+
DaemonSuccessEnvelope,
|
|
17
|
+
HealthResult,
|
|
18
|
+
)
|
|
19
|
+
from androidctl_contracts.user_state import ActiveDaemonRecord
|
|
20
|
+
from androidctld import SERVICE_NAME
|
|
21
|
+
from androidctld.config import TOKEN_HEADER_NAME, normalize_loopback_host
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _Opener(Protocol):
|
|
25
|
+
def open(self, request: Request, timeout: float) -> Any: ...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class OwnershipHealthStatus(str, Enum):
|
|
29
|
+
LIVE_MATCH = "live_match"
|
|
30
|
+
LIVE_MISMATCH = "live_mismatch"
|
|
31
|
+
UNREACHABLE = "unreachable"
|
|
32
|
+
UNPROBEABLE = "unprobeable"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class OwnershipHealthProbeResult:
|
|
37
|
+
status: OwnershipHealthStatus
|
|
38
|
+
token: str | None = None
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def is_live(self) -> bool:
|
|
42
|
+
return self.status in {
|
|
43
|
+
OwnershipHealthStatus.LIVE_MATCH,
|
|
44
|
+
OwnershipHealthStatus.LIVE_MISMATCH,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class OwnershipHealthProbe:
|
|
49
|
+
_TIMEOUT_SECONDS = 0.15
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
*,
|
|
54
|
+
opener_factory: Callable[[], _Opener | OpenerDirector] | None = None,
|
|
55
|
+
timeout_seconds: float | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
self._opener_factory = opener_factory or self._build_proxy_bypassing_opener
|
|
58
|
+
self._timeout_seconds = timeout_seconds or self._TIMEOUT_SECONDS
|
|
59
|
+
|
|
60
|
+
def probe(
|
|
61
|
+
self,
|
|
62
|
+
*,
|
|
63
|
+
host: str,
|
|
64
|
+
port: int,
|
|
65
|
+
owner_id: str,
|
|
66
|
+
workspace_root: str,
|
|
67
|
+
expected_workspace_root: str,
|
|
68
|
+
expected_owner_id: str,
|
|
69
|
+
tokens: Iterable[str],
|
|
70
|
+
) -> OwnershipHealthProbeResult:
|
|
71
|
+
if port <= 0:
|
|
72
|
+
return OwnershipHealthProbeResult(OwnershipHealthStatus.UNPROBEABLE)
|
|
73
|
+
try:
|
|
74
|
+
normalized_host = normalize_loopback_host(host)
|
|
75
|
+
except ValueError:
|
|
76
|
+
return OwnershipHealthProbeResult(OwnershipHealthStatus.UNPROBEABLE)
|
|
77
|
+
|
|
78
|
+
opener = self._opener_factory()
|
|
79
|
+
url = f"http://{self._url_host(normalized_host)}:{port}/health"
|
|
80
|
+
mismatch: OwnershipHealthProbeResult | None = None
|
|
81
|
+
for token in self._probe_tokens(tokens):
|
|
82
|
+
headers = {
|
|
83
|
+
TOKEN_HEADER_NAME: token,
|
|
84
|
+
OWNER_HEADER_NAME: owner_id,
|
|
85
|
+
}
|
|
86
|
+
request = Request(url, method="POST", data=b"{}", headers=headers)
|
|
87
|
+
try:
|
|
88
|
+
with opener.open(request, timeout=self._timeout_seconds) as response:
|
|
89
|
+
payload = json.loads(response.read().decode("utf-8"))
|
|
90
|
+
except HTTPError as error:
|
|
91
|
+
try:
|
|
92
|
+
payload = json.loads(error.read().decode("utf-8"))
|
|
93
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
94
|
+
error.close()
|
|
95
|
+
continue
|
|
96
|
+
error.close()
|
|
97
|
+
except (OSError, URLError, ValueError, json.JSONDecodeError):
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
status = self._classify_health_payload(
|
|
101
|
+
payload,
|
|
102
|
+
token=token,
|
|
103
|
+
owner_id=owner_id,
|
|
104
|
+
workspace_root=workspace_root,
|
|
105
|
+
expected_workspace_root=expected_workspace_root,
|
|
106
|
+
expected_owner_id=expected_owner_id,
|
|
107
|
+
)
|
|
108
|
+
if status is None:
|
|
109
|
+
continue
|
|
110
|
+
if status.status == OwnershipHealthStatus.LIVE_MATCH:
|
|
111
|
+
return status
|
|
112
|
+
if status.status == OwnershipHealthStatus.LIVE_MISMATCH:
|
|
113
|
+
mismatch = status
|
|
114
|
+
continue
|
|
115
|
+
return status
|
|
116
|
+
if mismatch is not None:
|
|
117
|
+
return mismatch
|
|
118
|
+
return OwnershipHealthProbeResult(OwnershipHealthStatus.UNREACHABLE)
|
|
119
|
+
|
|
120
|
+
def _classify_health_payload(
|
|
121
|
+
self,
|
|
122
|
+
payload: object,
|
|
123
|
+
*,
|
|
124
|
+
token: str,
|
|
125
|
+
owner_id: str,
|
|
126
|
+
workspace_root: str,
|
|
127
|
+
expected_workspace_root: str,
|
|
128
|
+
expected_owner_id: str,
|
|
129
|
+
) -> OwnershipHealthProbeResult | None:
|
|
130
|
+
try:
|
|
131
|
+
envelope = DaemonSuccessEnvelope[HealthResult].model_validate(payload)
|
|
132
|
+
except ValueError:
|
|
133
|
+
return self._classify_error_payload(payload)
|
|
134
|
+
|
|
135
|
+
result = envelope.result
|
|
136
|
+
if result.service != SERVICE_NAME:
|
|
137
|
+
return None
|
|
138
|
+
if (
|
|
139
|
+
result.workspace_root == expected_workspace_root
|
|
140
|
+
and result.owner_id == expected_owner_id
|
|
141
|
+
and workspace_root == expected_workspace_root
|
|
142
|
+
and owner_id == expected_owner_id
|
|
143
|
+
):
|
|
144
|
+
return OwnershipHealthProbeResult(
|
|
145
|
+
OwnershipHealthStatus.LIVE_MATCH,
|
|
146
|
+
token=token,
|
|
147
|
+
)
|
|
148
|
+
return OwnershipHealthProbeResult(OwnershipHealthStatus.LIVE_MISMATCH)
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def _classify_error_payload(
|
|
152
|
+
payload: object,
|
|
153
|
+
) -> OwnershipHealthProbeResult | None:
|
|
154
|
+
try:
|
|
155
|
+
envelope = DaemonErrorEnvelope.model_validate(payload)
|
|
156
|
+
except ValueError:
|
|
157
|
+
return None
|
|
158
|
+
if envelope.error.code.value in {"DAEMON_UNAUTHORIZED", "WORKSPACE_BUSY"}:
|
|
159
|
+
return OwnershipHealthProbeResult(OwnershipHealthStatus.LIVE_MISMATCH)
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
def probe_active_record(
|
|
163
|
+
self,
|
|
164
|
+
record: ActiveDaemonRecord,
|
|
165
|
+
*,
|
|
166
|
+
expected_workspace_root: str,
|
|
167
|
+
expected_owner_id: str,
|
|
168
|
+
) -> OwnershipHealthProbeResult:
|
|
169
|
+
return self.probe(
|
|
170
|
+
host=record.host,
|
|
171
|
+
port=record.port,
|
|
172
|
+
owner_id=record.owner_id,
|
|
173
|
+
workspace_root=record.workspace_root,
|
|
174
|
+
expected_workspace_root=expected_workspace_root,
|
|
175
|
+
expected_owner_id=expected_owner_id,
|
|
176
|
+
tokens=(record.token,),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
@staticmethod
|
|
180
|
+
def _unique_tokens(tokens: Iterable[str]) -> list[str]:
|
|
181
|
+
unique: list[str] = []
|
|
182
|
+
seen: set[str] = set()
|
|
183
|
+
for token in tokens:
|
|
184
|
+
token = token.strip()
|
|
185
|
+
if not token or token in seen:
|
|
186
|
+
continue
|
|
187
|
+
unique.append(token)
|
|
188
|
+
seen.add(token)
|
|
189
|
+
return unique
|
|
190
|
+
|
|
191
|
+
@classmethod
|
|
192
|
+
def _probe_tokens(cls, tokens: Iterable[str]) -> list[str]:
|
|
193
|
+
unique = cls._unique_tokens(tokens)
|
|
194
|
+
return unique or [""]
|
|
195
|
+
|
|
196
|
+
@staticmethod
|
|
197
|
+
def _url_host(host: str) -> str:
|
|
198
|
+
if ":" in host and not host.startswith("["):
|
|
199
|
+
return f"[{host}]"
|
|
200
|
+
return host
|
|
201
|
+
|
|
202
|
+
@staticmethod
|
|
203
|
+
def _build_proxy_bypassing_opener() -> OpenerDirector:
|
|
204
|
+
return build_opener(ProxyHandler({}))
|