drukbox-python-sdk 0.0.2__tar.gz → 0.0.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drukbox-python-sdk
3
- Version: 0.0.2
3
+ Version: 0.0.4
4
4
  Summary: Async Python client for Drukbox.
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: httpx>=0.28
@@ -65,6 +65,7 @@ Public exports live in `drukbox_sdk`:
65
65
 
66
66
  - `SandboxAPI`
67
67
  - `SandboxHost`
68
+ - `DoctorReport` and `DoctorCheck`
68
69
  - `SandboxAPIError` and typed subclasses for auth, not found, conflict,
69
70
  unavailable, and unclassified response errors
70
71
 
@@ -75,11 +76,18 @@ Supported host operations:
75
76
  - `attach`
76
77
  - `list_hosts`
77
78
  - `delete_host`
79
+ - `doctor`
78
80
  - `aclose`
79
81
 
80
82
  `create_host` supports the service's optional `image`, `env`, `expires_at`,
81
83
  and `Idempotency-Key` inputs.
82
84
 
85
+ `doctor` fetches `GET /doctor` — read-only dependency health. The service
86
+ runs one cheap, non-mutating probe per dependency (database, active VM
87
+ provider, Tailscale when enabled) and always responds 200, so callers branch
88
+ on `DoctorReport.ok` rather than the HTTP status. A failed `DoctorCheck`
89
+ carries a stable `hint` slug for remediation.
90
+
83
91
  The SDK does not mint Tailscale auth keys, manage ACLs, establish SSH,
84
92
  provision Linux users, transfer files, or run remote commands.
85
93
 
@@ -57,6 +57,7 @@ Public exports live in `drukbox_sdk`:
57
57
 
58
58
  - `SandboxAPI`
59
59
  - `SandboxHost`
60
+ - `DoctorReport` and `DoctorCheck`
60
61
  - `SandboxAPIError` and typed subclasses for auth, not found, conflict,
61
62
  unavailable, and unclassified response errors
62
63
 
@@ -67,11 +68,18 @@ Supported host operations:
67
68
  - `attach`
68
69
  - `list_hosts`
69
70
  - `delete_host`
71
+ - `doctor`
70
72
  - `aclose`
71
73
 
72
74
  `create_host` supports the service's optional `image`, `env`, `expires_at`,
73
75
  and `Idempotency-Key` inputs.
74
76
 
77
+ `doctor` fetches `GET /doctor` — read-only dependency health. The service
78
+ runs one cheap, non-mutating probe per dependency (database, active VM
79
+ provider, Tailscale when enabled) and always responds 200, so callers branch
80
+ on `DoctorReport.ok` rather than the HTTP status. A failed `DoctorCheck`
81
+ carries a stable `hint` slug for remediation.
82
+
75
83
  The SDK does not mint Tailscale auth keys, manage ACLs, establish SSH,
76
84
  provision Linux users, transfer files, or run remote commands.
77
85
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "drukbox-python-sdk"
7
- version = "0.0.2"
7
+ version = "0.0.4"
8
8
  description = "Async Python client for Drukbox."
9
9
  readme = "README.md"
10
10
  # Tracks the lowest Python any current consumer supports. Bump only
@@ -5,6 +5,8 @@ detail and may change without notice.
5
5
  """
6
6
 
7
7
  from .api import (
8
+ DoctorCheck,
9
+ DoctorReport,
8
10
  SandboxAPI,
9
11
  SandboxHost,
10
12
  )
@@ -19,6 +21,8 @@ from .exceptions import (
19
21
  )
20
22
 
21
23
  __all__ = [
24
+ "DoctorCheck",
25
+ "DoctorReport",
22
26
  "SandboxAPI",
23
27
  "SandboxAPIError",
24
28
  "SandboxAuthError",
@@ -86,6 +86,7 @@ class SandboxHost:
86
86
  image: str
87
87
  external_ssh_host: str
88
88
  external_ssh_port: int
89
+ ssh_username: str
89
90
  internal_ssh_host: str | None
90
91
  known_hosts: str
91
92
  tailscale_device_id: str | None
@@ -111,6 +112,46 @@ def _parse_sandbox_host(data: dict[str, Any]) -> SandboxHost:
111
112
  return SandboxHost(**{key: data[key] for key in _SANDBOX_HOST_FIELDS})
112
113
 
113
114
 
115
+ @dataclass(frozen=True)
116
+ class DoctorCheck:
117
+ """One dependency probe. ``hint`` is a remediation slug, set only on failures."""
118
+
119
+ name: str
120
+ status: str
121
+ detail: str | None
122
+ latency_ms: int | None
123
+ hint: str | None
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class DoctorReport:
128
+ """Health snapshot. The HTTP status is always 200 — branch on ``ok``."""
129
+
130
+ ok: bool
131
+ active_provider: str
132
+ tailscale_enabled: bool
133
+ checks: list[DoctorCheck]
134
+
135
+
136
+ def _parse_doctor_check(data: dict[str, Any]) -> DoctorCheck:
137
+ return DoctorCheck(
138
+ name=data["name"],
139
+ status=data["status"],
140
+ detail=data["detail"],
141
+ latency_ms=data["latency_ms"],
142
+ hint=data["hint"],
143
+ )
144
+
145
+
146
+ def _parse_doctor_report(data: dict[str, Any]) -> DoctorReport:
147
+ return DoctorReport(
148
+ ok=data["ok"],
149
+ active_provider=data["active_provider"],
150
+ tailscale_enabled=data["tailscale_enabled"],
151
+ checks=[_parse_doctor_check(check) for check in data["checks"]],
152
+ )
153
+
154
+
114
155
  class SandboxAPI:
115
156
  """Async client for Drukbox.
116
157
 
@@ -230,6 +271,17 @@ class SandboxAPI:
230
271
  async def delete_host(self, host_id: uuid.UUID | str) -> None:
231
272
  await self._request("DELETE", f"/hosts/{host_id}")
232
273
 
274
+ async def doctor(self) -> DoctorReport:
275
+ """Fetch ``GET /doctor`` — read-only dependency health.
276
+
277
+ Always responds 200; inspect :attr:`DoctorReport.ok`, not the HTTP
278
+ status. Auth failures raise :class:`SandboxAuthError`.
279
+ """
280
+
281
+ data = await self._request("GET", "/doctor")
282
+ assert isinstance(data, dict)
283
+ return _parse_doctor_report(data)
284
+
233
285
  async def aclose(self) -> None:
234
286
  if self._client is None:
235
287
  return
@@ -22,6 +22,7 @@ import pytest
22
22
  import respx
23
23
 
24
24
  from drukbox_sdk import (
25
+ DoctorReport,
25
26
  SandboxAPI,
26
27
  SandboxAuthError,
27
28
  SandboxConflictError,
@@ -35,6 +36,41 @@ from drukbox_sdk import (
35
36
  BASE_URL = "https://sandbox.test"
36
37
 
37
38
 
39
+ def _doctor_payload(**overrides: Any) -> dict[str, Any]:
40
+ """One canonical /doctor payload used across tests; overrides per case."""
41
+
42
+ payload: dict[str, Any] = {
43
+ "ok": True,
44
+ "active_provider": "aws",
45
+ "tailscale_enabled": True,
46
+ "checks": [
47
+ {
48
+ "name": "db",
49
+ "status": "ok",
50
+ "detail": "select 1 -> 1",
51
+ "latency_ms": 2,
52
+ "hint": None,
53
+ },
54
+ {
55
+ "name": "provider",
56
+ "status": "ok",
57
+ "detail": "account=111122223333 arn=arn:aws:iam::111122223333:user/drukbox",
58
+ "latency_ms": 142,
59
+ "hint": None,
60
+ },
61
+ {
62
+ "name": "tailscale",
63
+ "status": "ok",
64
+ "detail": "tailnet=example.ts.net devices=3",
65
+ "latency_ms": 88,
66
+ "hint": None,
67
+ },
68
+ ],
69
+ }
70
+ payload.update(overrides)
71
+ return payload
72
+
73
+
38
74
  def _host_payload(**overrides: Any) -> dict[str, Any]:
39
75
  """One canonical host payload used across tests; overrides per case."""
40
76
 
@@ -46,6 +82,7 @@ def _host_payload(**overrides: Any) -> dict[str, Any]:
46
82
  "image": "ghcr.io/drukbox/sandbox:test",
47
83
  "external_ssh_host": "203.0.113.42",
48
84
  "external_ssh_port": 22,
85
+ "ssh_username": "exedev",
49
86
  "internal_ssh_host": "host-abc.example.ts.net",
50
87
  "known_hosts": "ssh-ed25519 AAAA...\n",
51
88
  "tailscale_device_id": None,
@@ -103,6 +140,7 @@ async def test_create_host_posts_payload_and_returns_parsed_host(api: SandboxAPI
103
140
  assert host.id == payload["id"]
104
141
  assert host.external_ssh_host == payload["external_ssh_host"]
105
142
  assert host.external_ssh_port == payload["external_ssh_port"]
143
+ assert host.ssh_username == payload["ssh_username"]
106
144
  assert host.internal_ssh_host == payload["internal_ssh_host"]
107
145
  assert host.tailscale_device_id is None
108
146
  assert host.activated_at is None
@@ -449,3 +487,95 @@ def test_from_env_strips_trailing_slash_via_constructor(
449
487
  sandbox = SandboxAPI.from_env()
450
488
 
451
489
  assert sandbox.base_url == "https://trailing.test"
490
+
491
+
492
+ @respx.mock
493
+ async def test_doctor_parses_report_and_checks(api: SandboxAPI):
494
+ route = respx.get(f"{BASE_URL}/doctor").mock(
495
+ return_value=httpx.Response(200, json=_doctor_payload()),
496
+ )
497
+
498
+ report = await api.doctor()
499
+
500
+ assert route.called
501
+ assert route.calls.last.request.headers["Authorization"] == "Bearer t-test"
502
+ assert isinstance(report, DoctorReport)
503
+ assert report.ok is True
504
+ assert report.active_provider == "aws"
505
+ assert report.tailscale_enabled is True
506
+ assert [check.name for check in report.checks] == ["db", "provider", "tailscale"]
507
+ provider = next(check for check in report.checks if check.name == "provider")
508
+ assert provider.status == "ok"
509
+ assert provider.latency_ms == 142
510
+ assert provider.hint is None
511
+
512
+
513
+ @respx.mock
514
+ async def test_doctor_surfaces_failure_with_hint(api: SandboxAPI):
515
+ """A failed probe round-trips status, detail, and the remediation hint;
516
+ the HTTP status is still 200, so callers branch on report.ok."""
517
+
518
+ payload = _doctor_payload(
519
+ ok=False,
520
+ checks=[
521
+ {
522
+ "name": "db",
523
+ "status": "ok",
524
+ "detail": "select 1 -> 1",
525
+ "latency_ms": 2,
526
+ "hint": None,
527
+ },
528
+ {
529
+ "name": "tailscale",
530
+ "status": "fail",
531
+ "detail": "Tailscale API authentication failed",
532
+ "latency_ms": 380,
533
+ "hint": "check_tailscale_oauth_and_api_reachability",
534
+ },
535
+ ],
536
+ )
537
+ respx.get(f"{BASE_URL}/doctor").mock(return_value=httpx.Response(200, json=payload))
538
+
539
+ report = await api.doctor()
540
+
541
+ assert report.ok is False
542
+ failed = next(check for check in report.checks if check.status == "fail")
543
+ assert failed.name == "tailscale"
544
+ assert failed.detail == "Tailscale API authentication failed"
545
+ assert failed.hint == "check_tailscale_oauth_and_api_reachability"
546
+
547
+
548
+ @respx.mock
549
+ async def test_doctor_ignores_unknown_check_fields(api: SandboxAPI):
550
+ """A future service adding a field to a check must not break today's SDK."""
551
+
552
+ payload = _doctor_payload(
553
+ checks=[
554
+ {
555
+ "name": "db",
556
+ "status": "ok",
557
+ "detail": "select 1 -> 1",
558
+ "latency_ms": 2,
559
+ "hint": None,
560
+ "future_field": "surprise",
561
+ },
562
+ ],
563
+ )
564
+ respx.get(f"{BASE_URL}/doctor").mock(return_value=httpx.Response(200, json=payload))
565
+
566
+ report = await api.doctor()
567
+
568
+ assert report.checks[0].name == "db"
569
+ assert not hasattr(report.checks[0], "future_field")
570
+
571
+
572
+ @respx.mock
573
+ async def test_doctor_401_raises_sandbox_auth_error(api: SandboxAPI):
574
+ """Doctor is service-token only; a bad token raises like every other call."""
575
+
576
+ respx.get(f"{BASE_URL}/doctor").mock(
577
+ return_value=httpx.Response(401, json={"detail": "service token required"}),
578
+ )
579
+
580
+ with pytest.raises(SandboxAuthError, match="service token required"):
581
+ await api.doctor()