ushabti 0.1.0.dev0__tar.gz → 0.1.0.dev1__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.
Files changed (30) hide show
  1. {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/PKG-INFO +3 -1
  2. {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/README.md +2 -0
  3. {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/pyproject.toml +1 -1
  4. {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/pyproject.toml.orig +1 -1
  5. {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/errors.py +8 -0
  6. {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/field.py +9 -1
  7. {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/implements/__init__.py +21 -1
  8. ushabti-0.1.0.dev1/src/ushabti/implements/kubernetes.py +444 -0
  9. ushabti-0.1.0.dev1/src/ushabti/implements/prometheus.py +286 -0
  10. ushabti-0.1.0.dev0/src/ushabti/implements/kubernetes.py +0 -112
  11. ushabti-0.1.0.dev0/src/ushabti/implements/prometheus.py +0 -61
  12. {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/LICENSE +0 -0
  13. {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/__init__.py +0 -0
  14. {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/_base.py +0 -0
  15. {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/config.py +0 -0
  16. {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/here_am_i.py +0 -0
  17. {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/implements/noop.py +0 -0
  18. {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/implements/proxmox.py +0 -0
  19. {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/maat.py +0 -0
  20. {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/models/__init__.py +0 -0
  21. {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/models/anthropic.py +0 -0
  22. {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/models/fake.py +0 -0
  23. {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/models/openai_compatible.py +0 -0
  24. {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/omen.py +0 -0
  25. {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/papyrus.py +0 -0
  26. {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/py.typed +0 -0
  27. {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/reference.py +0 -0
  28. {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/spell.py +0 -0
  29. {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/summons.py +0 -0
  30. {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/weighing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ushabti
3
- Version: 0.1.0.dev0
3
+ Version: 0.1.0.dev1
4
4
  Summary: SDK for Ushabti, an open-source DevOps agent harness: spells, implements, weighing, and papyrus.
5
5
  Keywords: devops,sre,agents,monitoring,kubernetes,proxmox,shabti,shawabti
6
6
  License-Expression: Apache-2.0
@@ -45,6 +45,8 @@ It holds the vocabulary every other package shares:
45
45
  pip install ushabti # core only
46
46
  pip install "ushabti[anthropic]" # with the Anthropic SDK
47
47
  pip install "ushabti[openai]" # with the OpenAI SDK, for any OpenAI-compatible endpoint
48
+ pip install "ushabti[kubernetes]" # with the Kubernetes client, for the kubernetes.* implements
49
+ pip install "ushabti[prometheus]" # with httpx, for the prometheus.* implements
48
50
  ```
49
51
 
50
52
  This is the only Ushabti package on PyPI. The agent and the overseer ship as container images,
@@ -14,6 +14,8 @@ It holds the vocabulary every other package shares:
14
14
  pip install ushabti # core only
15
15
  pip install "ushabti[anthropic]" # with the Anthropic SDK
16
16
  pip install "ushabti[openai]" # with the OpenAI SDK, for any OpenAI-compatible endpoint
17
+ pip install "ushabti[kubernetes]" # with the Kubernetes client, for the kubernetes.* implements
18
+ pip install "ushabti[prometheus]" # with httpx, for the prometheus.* implements
17
19
  ```
18
20
 
19
21
  This is the only Ushabti package on PyPI. The agent and the overseer ship as container images,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ushabti"
3
- version = "0.1.0.dev0"
3
+ version = "0.1.0.dev1"
4
4
  description = "SDK for Ushabti, an open-source DevOps agent harness: spells, implements, weighing, and papyrus."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ushabti"
3
- version = "0.1.0.dev0"
3
+ version = "0.1.0.dev1"
4
4
  description = "SDK for Ushabti, an open-source DevOps agent harness: spells, implements, weighing, and papyrus."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -50,6 +50,14 @@ class UnknownImplementError(UshabtiError, LookupError):
50
50
  super().__init__(f"{name} is not a registered implement")
51
51
 
52
52
 
53
+ class ImplementError(UshabtiError):
54
+ """An implement could not do its work: bad arguments, no endpoint, or a failed call.
55
+
56
+ The message is safe to record in Papyrus. It never carries a secret or a
57
+ credential-bearing URL.
58
+ """
59
+
60
+
53
61
  class UnweighedWriteError(UshabtiError):
54
62
  """An implement was asked to run without a PASS verdict from the weighing.
55
63
 
@@ -25,7 +25,13 @@ class Field(StrictModel):
25
25
  """What the field is: `kubernetes`, `proxmox`, `prometheus`, or `service`."""
26
26
 
27
27
  namespaces: tuple[str, ...] = ()
28
- """Kubernetes namespaces to tend. Only valid when `kind` is `kubernetes`."""
28
+ """Kubernetes namespaces to tend. The Kubernetes implements refuse any other
29
+ namespace. Only valid when `kind` is `kubernetes`."""
30
+
31
+ context: str | None = None
32
+ """Kubeconfig context to reach the cluster through when the ushabti runs outside
33
+ it. Inside a cluster the pod's service account is used and this is ignored. Only
34
+ valid when `kind` is `kubernetes`."""
29
35
 
30
36
  nodes: tuple[str, ...] = ()
31
37
  """Proxmox node names to tend. Only valid when `kind` is `proxmox`."""
@@ -39,4 +45,6 @@ class Field(StrictModel):
39
45
  raise ValueError("namespaces is only valid when kind is kubernetes")
40
46
  if self.nodes and self.kind is not FieldKind.PROXMOX:
41
47
  raise ValueError("nodes is only valid when kind is proxmox")
48
+ if self.context is not None and self.kind is not FieldKind.KUBERNETES:
49
+ raise ValueError("context is only valid when kind is kubernetes")
42
50
  return self
@@ -14,7 +14,7 @@ from typing import TYPE_CHECKING, ClassVar, Final, final
14
14
  from pydantic import JsonValue
15
15
 
16
16
  from ushabti._base import WireModel
17
- from ushabti.errors import UnknownImplementError, UnweighedWriteError
17
+ from ushabti.errors import ImplementError, UnknownImplementError, UnweighedWriteError
18
18
  from ushabti.field import Field
19
19
 
20
20
  if TYPE_CHECKING:
@@ -167,6 +167,26 @@ class ImplementRegistry:
167
167
  return len(self._implements)
168
168
 
169
169
 
170
+ def text_argument(implement: str, arguments: Mapping[str, JsonValue], key: str) -> str:
171
+ """A required text argument. Numbers are accepted and written out; anything else is refused."""
172
+ value = optional_text_argument(implement, arguments, key)
173
+ if value is None:
174
+ raise ImplementError(f"{implement}: the argument '{key}' is required")
175
+ return value
176
+
177
+
178
+ def optional_text_argument(
179
+ implement: str, arguments: Mapping[str, JsonValue], key: str
180
+ ) -> str | None:
181
+ """An optional text argument: None when absent, ImplementError when it is not text."""
182
+ value = arguments.get(key)
183
+ if value is None:
184
+ return None
185
+ if isinstance(value, bool) or not isinstance(value, str | int | float):
186
+ raise ImplementError(f"{implement}: the argument '{key}' must be a string")
187
+ return str(value)
188
+
189
+
170
190
  def builtin_implements() -> tuple[Implement, ...]:
171
191
  """Fresh instances of every implement that ships with the SDK."""
172
192
  # Imported here because these modules import Implement from this one.
@@ -0,0 +1,444 @@
1
+ """Kubernetes implements: pods and deployments through the Kubernetes API.
2
+
3
+ The two read implements are built. The two write implements are still scaffold
4
+ stubs. All four need the ``kubernetes`` extra: pip install "ushabti[kubernetes]".
5
+ The extra is imported only when an implement runs, so the core imports without
6
+ it.
7
+
8
+ The field (the target) names the namespaces an ushabti tends, and may name a
9
+ kubeconfig context for runs outside the cluster::
10
+
11
+ fields:
12
+ - name: homelab-k3s
13
+ kind: kubernetes
14
+ namespaces: [default, monitoring]
15
+ context: homelab # only used outside the cluster
16
+
17
+ Inside a cluster the pod's service account is used. Outside it, the kubeconfig
18
+ that ``KUBECONFIG`` names, or ``~/.kube/config``, is read and never written. An
19
+ implement refuses a namespace the field does not list before it makes any call:
20
+ a spell can only look where it says it looks.
21
+
22
+ Results are shaped for Ma'at conditions. ``kubernetes.list_pods`` exposes
23
+ ``not_ready``, the number of pods that are not running with every container
24
+ ready, so a spell can write ``expect: {path: not_ready, op: eq, value: 0}``.
25
+ ``kubernetes.get_deployment`` exposes ``missing``, the desired replicas minus
26
+ the ready ones.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ from collections import Counter
33
+ from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
34
+ from contextlib import AbstractAsyncContextManager, asynccontextmanager
35
+ from dataclasses import dataclass
36
+ from datetime import UTC, datetime
37
+ from typing import TYPE_CHECKING, Any, ClassVar, Final, cast
38
+
39
+ from pydantic import JsonValue
40
+
41
+ from ushabti._base import utc_now
42
+ from ushabti.errors import ImplementError, MissingExtraError, UshabtiError
43
+ from ushabti.field import Field, FieldKind
44
+ from ushabti.implements import Implement, ImplementKind, optional_text_argument, text_argument
45
+
46
+ if TYPE_CHECKING:
47
+ from kubernetes.aio.client import V1ContainerStatus, V1Deployment, V1Pod
48
+
49
+ TIMEOUT_SECONDS: Final = 15.0
50
+ _DETAIL_LIMIT: Final = 300
51
+
52
+ _NAMESPACE: dict[str, JsonValue] = {
53
+ "type": "string",
54
+ "description": "Namespace to look in. Must be one of the field's namespaces.",
55
+ }
56
+ _DEPLOYMENT: dict[str, JsonValue] = {"type": "string", "description": "Name of the deployment."}
57
+
58
+
59
+ @dataclass(frozen=True, slots=True)
60
+ class Cluster:
61
+ """The API objects one call uses. Tests build one around fakes."""
62
+
63
+ core: Any
64
+ """A ``CoreV1Api``, for pods."""
65
+
66
+ apps: Any
67
+ """An ``AppsV1Api``, for deployments."""
68
+
69
+
70
+ Connect = Callable[[Field], AbstractAsyncContextManager[Cluster]]
71
+ """Opens the field's cluster for the duration of one call."""
72
+
73
+
74
+ class _KubernetesImplement(Implement):
75
+ """What the Kubernetes reads share: the namespace check and the connection."""
76
+
77
+ kind: ClassVar[ImplementKind] = ImplementKind.READ
78
+
79
+ def __init__(
80
+ self, *, connect: Connect | None = None, clock: Callable[[], datetime] = utc_now
81
+ ) -> None:
82
+ # Both are only given by tests: a fake cluster, and a fixed "now" for ages.
83
+ self._connect = connect
84
+ self._clock = clock
85
+
86
+ def _scope(self, arguments: Mapping[str, JsonValue], field: Field | None) -> tuple[Field, str]:
87
+ """The field and the namespace argument, refused unless the field lists it."""
88
+ namespace = text_argument(self.name, arguments, "namespace")
89
+ if field is None or field.kind is not FieldKind.KUBERNETES:
90
+ raise ImplementError(
91
+ f"{self.name} needs a field of kind kubernetes; "
92
+ "name one on the assertion or the summons"
93
+ )
94
+ if not field.namespaces:
95
+ raise ImplementError(
96
+ f"{self.name}: field {field.name} lists no namespaces; add the ones it tends"
97
+ )
98
+ if namespace not in field.namespaces:
99
+ raise ImplementError(
100
+ f"{self.name}: namespace {namespace} is not one of field {field.name}'s "
101
+ f"namespaces ({', '.join(field.namespaces)})"
102
+ )
103
+ return field, namespace
104
+
105
+ async def _call[T](self, field: Field, request: Callable[[Cluster], Awaitable[T]]) -> T:
106
+ """Open the cluster, make one request, and explain the client's failures."""
107
+ connect = _connect if self._connect is None else self._connect
108
+ try:
109
+ async with connect(field) as cluster:
110
+ return await request(cluster)
111
+ except UshabtiError:
112
+ raise
113
+ except Exception as error:
114
+ explained = _explain(self.name, error)
115
+ if explained is None:
116
+ raise
117
+ raise explained from error
118
+
119
+
120
+ class ListPods(_KubernetesImplement):
121
+ """The pods of one namespace: phase, readiness, restarts, and what is wrong."""
122
+
123
+ name: ClassVar[str] = "kubernetes.list_pods"
124
+ description: ClassVar[str] = (
125
+ "List the pods in a namespace with their phase and readiness. Returns each pod, "
126
+ "the count, `not_ready` (pods that are not running with every container ready, "
127
+ "finished jobs excluded), the total restarts, and the reasons containers are "
128
+ "waiting or have failed, such as CrashLoopBackOff."
129
+ )
130
+ parameters: ClassVar[Mapping[str, JsonValue]] = {
131
+ "type": "object",
132
+ "properties": {
133
+ "namespace": _NAMESPACE,
134
+ "label_selector": {
135
+ "type": "string",
136
+ "description": "Optional label selector, such as app=web.",
137
+ },
138
+ },
139
+ "required": ["namespace"],
140
+ "additionalProperties": False,
141
+ }
142
+
143
+ async def _run(self, arguments: Mapping[str, JsonValue], field: Field | None) -> JsonValue:
144
+ field, namespace = self._scope(arguments, field)
145
+ selector = optional_text_argument(self.name, arguments, "label_selector")
146
+
147
+ async def request(cluster: Cluster) -> Any:
148
+ return await cluster.core.list_namespaced_pod(
149
+ namespace, label_selector=selector, _request_timeout=TIMEOUT_SECONDS
150
+ )
151
+
152
+ listed = await self._call(field, request)
153
+ return _pods(namespace, selector, listed.items or [], self._clock())
154
+
155
+
156
+ class GetDeployment(_KubernetesImplement):
157
+ """One deployment: how many replicas it wants, how many it has, and why not."""
158
+
159
+ name: ClassVar[str] = "kubernetes.get_deployment"
160
+ description: ClassVar[str] = (
161
+ "Get one deployment with its desired and ready replica counts. Returns the "
162
+ "counts under `replicas`, `missing` (desired minus ready), whether it is `ready`, "
163
+ "its conditions, and the images it runs."
164
+ )
165
+ parameters: ClassVar[Mapping[str, JsonValue]] = {
166
+ "type": "object",
167
+ "properties": {"namespace": _NAMESPACE, "name": _DEPLOYMENT},
168
+ "required": ["namespace", "name"],
169
+ "additionalProperties": False,
170
+ }
171
+
172
+ async def _run(self, arguments: Mapping[str, JsonValue], field: Field | None) -> JsonValue:
173
+ field, namespace = self._scope(arguments, field)
174
+ name = text_argument(self.name, arguments, "name")
175
+
176
+ async def request(cluster: Cluster) -> Any:
177
+ return await cluster.apps.read_namespaced_deployment(
178
+ name, namespace, _request_timeout=TIMEOUT_SECONDS
179
+ )
180
+
181
+ deployment = await self._call(field, request)
182
+ return _deployment(namespace, name, deployment, self._clock())
183
+
184
+
185
+ class RestartDeployment(Implement):
186
+ name: ClassVar[str] = "kubernetes.restart_deployment"
187
+ kind: ClassVar[ImplementKind] = ImplementKind.WRITE
188
+ description: ClassVar[str] = "Restart a deployment by rolling its pods."
189
+ parameters: ClassVar[Mapping[str, JsonValue]] = {
190
+ "type": "object",
191
+ "properties": {"namespace": _NAMESPACE, "name": _DEPLOYMENT},
192
+ "required": ["namespace", "name"],
193
+ "additionalProperties": False,
194
+ }
195
+
196
+ async def _run(self, arguments: Mapping[str, JsonValue], field: Field | None) -> JsonValue:
197
+ # TODO(scaffold): trigger a rolling restart of the deployment with the Kubernetes client.
198
+ raise NotImplementedError(
199
+ "kubernetes.restart_deployment is a scaffold stub: the rolling restart through "
200
+ "the Kubernetes client (the `kubernetes` extra) is not implemented yet"
201
+ )
202
+
203
+
204
+ class ScaleDeployment(Implement):
205
+ name: ClassVar[str] = "kubernetes.scale_deployment"
206
+ kind: ClassVar[ImplementKind] = ImplementKind.WRITE
207
+ description: ClassVar[str] = "Set the replica count of a deployment."
208
+ parameters: ClassVar[Mapping[str, JsonValue]] = {
209
+ "type": "object",
210
+ "properties": {
211
+ "namespace": _NAMESPACE,
212
+ "name": _DEPLOYMENT,
213
+ "replicas": {
214
+ "type": "integer",
215
+ "minimum": 0,
216
+ "description": "Replica count to set.",
217
+ },
218
+ },
219
+ "required": ["namespace", "name", "replicas"],
220
+ "additionalProperties": False,
221
+ }
222
+
223
+ async def _run(self, arguments: Mapping[str, JsonValue], field: Field | None) -> JsonValue:
224
+ # TODO(scaffold): set the deployment's replica count with the Kubernetes client.
225
+ raise NotImplementedError(
226
+ "kubernetes.scale_deployment is a scaffold stub: scaling a deployment through "
227
+ "the Kubernetes client (the `kubernetes` extra) is not implemented yet"
228
+ )
229
+
230
+
231
+ def implements() -> tuple[Implement, ...]:
232
+ return (ListPods(), GetDeployment(), RestartDeployment(), ScaleDeployment())
233
+
234
+
235
+ # Reaching the cluster
236
+
237
+
238
+ @asynccontextmanager
239
+ async def _connect(field: Field) -> AsyncIterator[Cluster]:
240
+ """The field's cluster: this pod's service account, or a kubeconfig context."""
241
+ try:
242
+ from kubernetes.aio import client, config
243
+ except ImportError as error:
244
+ raise MissingExtraError("kubernetes", "kubernetes") from error
245
+
246
+ configuration = client.Configuration()
247
+ try:
248
+ config.load_incluster_config(client_configuration=configuration) # type: ignore[no-untyped-call]
249
+ except config.ConfigException:
250
+ try:
251
+ # Never persist: a refreshed token is not written back to the kubeconfig.
252
+ await config.load_kube_config( # type: ignore[no-untyped-call]
253
+ context=field.context, client_configuration=configuration, persist_config=False
254
+ )
255
+ except config.ConfigException as error:
256
+ raise ImplementError(
257
+ f"kubernetes: no cluster to reach for field {field.name}: not inside one, "
258
+ f"and no kubeconfig could be loaded ({error})"
259
+ ) from error
260
+ async with client.ApiClient(configuration) as api:
261
+ yield Cluster(core=client.CoreV1Api(api), apps=client.AppsV1Api(api))
262
+
263
+
264
+ def _explain(name: str, error: Exception) -> ImplementError | None:
265
+ """The ImplementError for a failure of the client, or None when the error is not one."""
266
+ try:
267
+ import aiohttp
268
+ from kubernetes.aio.client import ApiException
269
+ except ImportError: # pragma: no cover (only with the extra half installed)
270
+ return None
271
+ if isinstance(error, ApiException):
272
+ return ImplementError(
273
+ f"{name}: the cluster answered HTTP {error.status} ({error.reason}): "
274
+ f"{_detail(error.body)}"
275
+ )
276
+ if isinstance(error, aiohttp.ClientError | TimeoutError | OSError):
277
+ # The error text can carry the server address; the type name is enough.
278
+ return ImplementError(f"{name}: could not reach the cluster ({type(error).__name__})")
279
+ return None
280
+
281
+
282
+ def _detail(body: object) -> str:
283
+ """The message of an API Status body, or the body itself, cut short."""
284
+ text = body.decode(errors="replace") if isinstance(body, bytes) else str(body or "")
285
+ try:
286
+ parsed = json.loads(text)
287
+ except ValueError:
288
+ parsed = None
289
+ message = parsed.get("message") if isinstance(parsed, dict) else None
290
+ detail = str(message) if message else text
291
+ return detail[:_DETAIL_LIMIT] or "no detail given"
292
+
293
+
294
+ # Shaping the results
295
+
296
+
297
+ @dataclass(frozen=True, slots=True)
298
+ class _Pod:
299
+ name: str | None
300
+ phase: str | None
301
+ ready: bool
302
+ containers_ready: int
303
+ containers: int
304
+ restarts: int
305
+ reasons: tuple[str, ...]
306
+ node: str | None
307
+ age_seconds: int | None
308
+
309
+ def as_json(self) -> dict[str, JsonValue]:
310
+ return {
311
+ "name": self.name,
312
+ "phase": self.phase,
313
+ "ready": self.ready,
314
+ "containers_ready": self.containers_ready,
315
+ "containers": self.containers,
316
+ "restarts": self.restarts,
317
+ "reasons": list(self.reasons),
318
+ "node": self.node,
319
+ "age_seconds": self.age_seconds,
320
+ }
321
+
322
+
323
+ def _pods(
324
+ namespace: str, selector: str | None, items: list[V1Pod], now: datetime
325
+ ) -> dict[str, JsonValue]:
326
+ pods = [_pod(item, now) for item in items]
327
+ counted = Counter(pod.phase or "Unknown" for pod in pods)
328
+ phases: dict[str, JsonValue] = dict(sorted(counted.items()))
329
+ reasons = cast("list[JsonValue]", sorted({reason for pod in pods for reason in pod.reasons}))
330
+ listed: list[JsonValue] = [pod.as_json() for pod in pods]
331
+ return {
332
+ "namespace": namespace,
333
+ "label_selector": selector,
334
+ "count": len(pods),
335
+ "not_ready": sum(1 for pod in pods if not pod.ready and pod.phase != "Succeeded"),
336
+ "restarts": sum(pod.restarts for pod in pods),
337
+ "phases": phases,
338
+ "reasons": reasons,
339
+ "pods": listed,
340
+ }
341
+
342
+
343
+ def _pod(item: V1Pod, now: datetime) -> _Pod:
344
+ metadata, spec, status = item.metadata, item.spec, item.status
345
+ statuses = list(status.container_statuses or []) if status is not None else []
346
+ init_statuses = list(status.init_container_statuses or []) if status is not None else []
347
+ containers = len(spec.containers) if spec is not None else len(statuses)
348
+ containers_ready = sum(1 for container in statuses if container.ready)
349
+ phase = status.phase if status is not None else None
350
+ reasons: set[str] = set()
351
+ if status is not None and status.reason:
352
+ reasons.add(status.reason)
353
+ for container in (*init_statuses, *statuses):
354
+ reasons.update(_container_reasons(container))
355
+ return _Pod(
356
+ name=metadata.name if metadata is not None else None,
357
+ phase=phase,
358
+ ready=phase == "Running" and containers > 0 and containers_ready == containers,
359
+ containers_ready=containers_ready,
360
+ containers=containers,
361
+ restarts=sum(container.restart_count for container in statuses),
362
+ reasons=tuple(sorted(reasons)),
363
+ node=spec.node_name if spec is not None else None,
364
+ age_seconds=_age(metadata.creation_timestamp if metadata is not None else None, now),
365
+ )
366
+
367
+
368
+ def _container_reasons(status: V1ContainerStatus) -> set[str]:
369
+ """Why a container is waiting or has stopped, such as CrashLoopBackOff."""
370
+ found: set[str] = set()
371
+ state = status.state
372
+ if state is None:
373
+ return found
374
+ if state.waiting is not None and state.waiting.reason:
375
+ found.add(state.waiting.reason)
376
+ stopped = state.terminated.reason if state.terminated is not None else None
377
+ if stopped and stopped != "Completed":
378
+ found.add(stopped)
379
+ return found
380
+
381
+
382
+ def _deployment(
383
+ namespace: str, name: str, deployment: V1Deployment, now: datetime
384
+ ) -> dict[str, JsonValue]:
385
+ metadata, spec, status = deployment.metadata, deployment.spec, deployment.status
386
+ desired = spec.replicas if spec.replicas is not None else 1
387
+ ready = _count(status.ready_replicas if status is not None else None)
388
+ generation = metadata.generation if metadata is not None else None
389
+ observed = status.observed_generation if status is not None else None
390
+ current = generation is None or observed is None or observed >= generation
391
+ missing = max(desired - ready, 0)
392
+ conditions: list[JsonValue] = [
393
+ {
394
+ "type": condition.type,
395
+ "status": condition.status,
396
+ "reason": condition.reason,
397
+ "message": condition.message,
398
+ "since": (
399
+ condition.last_transition_time.isoformat()
400
+ if condition.last_transition_time is not None
401
+ else None
402
+ ),
403
+ }
404
+ for condition in (status.conditions or [] if status is not None else [])
405
+ ]
406
+ template = spec.template.spec
407
+ images: list[JsonValue] = [
408
+ container.image
409
+ for container in (template.containers if template is not None else [])
410
+ if container.image
411
+ ]
412
+ return {
413
+ "namespace": namespace,
414
+ "name": name,
415
+ "replicas": {
416
+ "desired": desired,
417
+ "ready": ready,
418
+ "available": _count(status.available_replicas if status is not None else None),
419
+ "updated": _count(status.updated_replicas if status is not None else None),
420
+ "unavailable": _count(status.unavailable_replicas if status is not None else None),
421
+ },
422
+ "missing": missing,
423
+ "ready": missing == 0 and current,
424
+ "paused": bool(spec.paused),
425
+ "generation": generation,
426
+ "observed_generation": observed,
427
+ "strategy": spec.strategy.type if spec.strategy is not None else None,
428
+ "images": images,
429
+ "conditions": conditions,
430
+ "age_seconds": _age(metadata.creation_timestamp if metadata is not None else None, now),
431
+ }
432
+
433
+
434
+ def _count(value: int | None) -> int:
435
+ return 0 if value is None else value
436
+
437
+
438
+ def _age(created: datetime | None, now: datetime) -> int | None:
439
+ """Seconds since creation. A naive timestamp is taken as UTC."""
440
+ if created is None:
441
+ return None
442
+ if created.tzinfo is None:
443
+ created = created.replace(tzinfo=UTC)
444
+ return max(int((now - created).total_seconds()), 0)
@@ -0,0 +1,286 @@
1
+ """Prometheus implements: instant and range queries over the HTTP API.
2
+
3
+ Both are read implements. They need the ``prometheus`` extra, which is httpx:
4
+ pip install "ushabti[prometheus]". The extra is imported only when a query
5
+ runs, so the core imports without it.
6
+
7
+ The field (the target) names the server::
8
+
9
+ fields:
10
+ - name: homelab-metrics
11
+ kind: prometheus
12
+ endpoint: ${PROMETHEUS_URL}
13
+
14
+ The endpoint is resolved when the implement runs, so it may be an environment
15
+ reference. It is never written to Papyrus: an observation holds the query and
16
+ the result, nothing about where they came from.
17
+
18
+ Results are shaped for Ma'at conditions. An instant query that returns one
19
+ sample exposes it at ``value``, so a spell can write
20
+ ``expect: {path: value, op: lt, value: 90}``. A range query with one series
21
+ exposes ``last``, ``min``, and ``max``. ``NaN`` and infinities become ``null``,
22
+ which Ma'at treats as unknown rather than as drift.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import math
28
+ from collections.abc import Mapping
29
+ from typing import TYPE_CHECKING, Any, ClassVar, Final, cast
30
+
31
+ from pydantic import JsonValue
32
+
33
+ from ushabti.errors import ImplementError, MissingExtraError, UnresolvedEnvError
34
+ from ushabti.field import Field, FieldKind
35
+ from ushabti.implements import Implement, ImplementKind, optional_text_argument, text_argument
36
+ from ushabti.spell import expand_env
37
+
38
+ if TYPE_CHECKING:
39
+ import httpx
40
+
41
+ TIMEOUT_SECONDS: Final = 15.0
42
+ _REASON_LIMIT: Final = 300
43
+
44
+ _QUERY: dict[str, JsonValue] = {"type": "string", "description": "PromQL expression to evaluate."}
45
+ _TIME: dict[str, JsonValue] = {
46
+ "type": "string",
47
+ "description": "Evaluation time, RFC 3339 or Unix seconds. Defaults to now.",
48
+ }
49
+
50
+
51
+ class _PrometheusImplement(Implement):
52
+ """What the two queries share: find the endpoint, call it, shape the errors."""
53
+
54
+ kind: ClassVar[ImplementKind] = ImplementKind.READ
55
+ path: ClassVar[str]
56
+
57
+ def __init__(
58
+ self,
59
+ *,
60
+ transport: httpx.AsyncBaseTransport | None = None,
61
+ timeout: float = TIMEOUT_SECONDS,
62
+ ) -> None:
63
+ # A transport is only given by tests, to answer without a network.
64
+ self._transport = transport
65
+ self._timeout = timeout
66
+
67
+ async def _call(self, field: Field | None, params: dict[str, str]) -> dict[str, Any]:
68
+ base_url = _endpoint(self.name, field)
69
+ try:
70
+ import httpx
71
+ except ImportError as error:
72
+ raise MissingExtraError("prometheus", "httpx") from error
73
+ try:
74
+ async with httpx.AsyncClient(
75
+ base_url=base_url, timeout=self._timeout, transport=self._transport
76
+ ) as client:
77
+ response = await client.get(self.path, params=params)
78
+ except httpx.HTTPError as error:
79
+ # The error text can carry the URL, which can carry credentials.
80
+ raise ImplementError(
81
+ f"{self.name}: could not reach Prometheus ({type(error).__name__})"
82
+ ) from error
83
+ return _data(self.name, response)
84
+
85
+
86
+ class Query(_PrometheusImplement):
87
+ """An instant query: the value of an expression at one point in time."""
88
+
89
+ name: ClassVar[str] = "prometheus.query"
90
+ description: ClassVar[str] = (
91
+ "Evaluate a PromQL expression at a single point in time. Returns the samples "
92
+ "with their labels, their count, and, when there is exactly one sample, its "
93
+ "number at `value`."
94
+ )
95
+ parameters: ClassVar[Mapping[str, JsonValue]] = {
96
+ "type": "object",
97
+ "properties": {"query": _QUERY, "time": _TIME},
98
+ "required": ["query"],
99
+ "additionalProperties": False,
100
+ }
101
+ path: ClassVar[str] = "/api/v1/query"
102
+
103
+ async def _run(self, arguments: Mapping[str, JsonValue], field: Field | None) -> JsonValue:
104
+ query = text_argument(self.name, arguments, "query")
105
+ params = {"query": query}
106
+ at = optional_text_argument(self.name, arguments, "time")
107
+ if at is not None:
108
+ params["time"] = at
109
+ data = await self._call(field, params)
110
+ return _shape(self.name, query, data)
111
+
112
+
113
+ class QueryRange(_PrometheusImplement):
114
+ """A range query: the values of an expression over a window of time."""
115
+
116
+ name: ClassVar[str] = "prometheus.query_range"
117
+ description: ClassVar[str] = (
118
+ "Evaluate a PromQL expression over a time range. Returns one series per label "
119
+ "set, each with its values and its first, last, min, and max, and, when there "
120
+ "is exactly one series, those four numbers at the top level."
121
+ )
122
+ parameters: ClassVar[Mapping[str, JsonValue]] = {
123
+ "type": "object",
124
+ "properties": {
125
+ "query": _QUERY,
126
+ "start": {
127
+ "type": "string",
128
+ "description": "Start of the range, RFC 3339 or Unix seconds.",
129
+ },
130
+ "end": {"type": "string", "description": "End of the range, RFC 3339 or Unix seconds."},
131
+ "step": {"type": "string", "description": "Resolution step, such as 60s, or seconds."},
132
+ },
133
+ "required": ["query", "start", "end", "step"],
134
+ "additionalProperties": False,
135
+ }
136
+ path: ClassVar[str] = "/api/v1/query_range"
137
+
138
+ async def _run(self, arguments: Mapping[str, JsonValue], field: Field | None) -> JsonValue:
139
+ params: dict[str, str] = {}
140
+ for key in ("query", "start", "end", "step"):
141
+ params[key] = text_argument(self.name, arguments, key)
142
+ data = await self._call(field, params)
143
+ return _shape(self.name, params["query"], data)
144
+
145
+
146
+ def implements() -> tuple[Implement, ...]:
147
+ return (Query(), QueryRange())
148
+
149
+
150
+ # Reaching the server
151
+
152
+
153
+ def _endpoint(name: str, field: Field | None) -> str:
154
+ if field is None or field.kind is not FieldKind.PROMETHEUS:
155
+ raise ImplementError(
156
+ f"{name} needs a field of kind prometheus; name one on the assertion or the summons"
157
+ )
158
+ if not field.endpoint:
159
+ raise ImplementError(
160
+ f"{name}: field {field.name} has no endpoint; set it to the Prometheus base URL, "
161
+ "usually as ${PROMETHEUS_URL}"
162
+ )
163
+ try:
164
+ return expand_env(field.endpoint)
165
+ except UnresolvedEnvError as error:
166
+ raise ImplementError(f"{name}: field {field.name}: {error}") from None
167
+
168
+
169
+ def _data(name: str, response: httpx.Response) -> dict[str, Any]:
170
+ try:
171
+ body = response.json()
172
+ except ValueError:
173
+ raise ImplementError(
174
+ f"{name}: Prometheus answered HTTP {response.status_code} with a body that is not JSON"
175
+ ) from None
176
+ if not isinstance(body, dict):
177
+ raise ImplementError(
178
+ f"{name}: Prometheus answered HTTP {response.status_code} with no object"
179
+ )
180
+ if body.get("status") != "success":
181
+ kind = body.get("errorType") or f"HTTP {response.status_code}"
182
+ reason = str(body.get("error") or "no reason given")[:_REASON_LIMIT]
183
+ raise ImplementError(f"{name}: Prometheus rejected the query ({kind}): {reason}")
184
+ data = body.get("data")
185
+ if not isinstance(data, dict):
186
+ raise ImplementError(f"{name}: Prometheus answered without a data object")
187
+ return data
188
+
189
+
190
+ # Shaping the result
191
+
192
+
193
+ def _shape(name: str, query: str, data: dict[str, Any]) -> dict[str, JsonValue]:
194
+ result_type = data.get("resultType")
195
+ result = data.get("result")
196
+ try:
197
+ if result_type == "vector":
198
+ return _vector(query, result)
199
+ if result_type == "scalar":
200
+ return _scalar(query, result)
201
+ if result_type == "string":
202
+ return _string(query, result)
203
+ if result_type == "matrix":
204
+ return _matrix(query, result)
205
+ except (KeyError, IndexError, TypeError, ValueError):
206
+ raise ImplementError(
207
+ f"{name}: Prometheus returned a {result_type} in an unexpected shape"
208
+ ) from None
209
+ raise ImplementError(f"{name}: Prometheus returned an unknown result type: {result_type!r}")
210
+
211
+
212
+ def _vector(query: str, result: Any) -> dict[str, JsonValue]:
213
+ samples: list[JsonValue] = [
214
+ {
215
+ "metric": dict(item.get("metric", {})),
216
+ "timestamp": float(item["value"][0]),
217
+ "value": _number(item["value"][1]),
218
+ }
219
+ for item in result
220
+ ]
221
+ only = samples[0] if len(samples) == 1 else None
222
+ return {
223
+ "query": query,
224
+ "result_type": "vector",
225
+ "count": len(samples),
226
+ "value": only["value"] if isinstance(only, dict) else None,
227
+ "samples": samples,
228
+ }
229
+
230
+
231
+ def _scalar(query: str, result: Any) -> dict[str, JsonValue]:
232
+ timestamp, text = result
233
+ value = _number(text)
234
+ return {
235
+ "query": query,
236
+ "result_type": "scalar",
237
+ "count": 1,
238
+ "value": value,
239
+ "samples": [{"metric": {}, "timestamp": float(timestamp), "value": value}],
240
+ }
241
+
242
+
243
+ def _string(query: str, result: Any) -> dict[str, JsonValue]:
244
+ timestamp, text = result
245
+ return {
246
+ "query": query,
247
+ "result_type": "string",
248
+ "count": 1,
249
+ "value": None,
250
+ "text": str(text),
251
+ "timestamp": float(timestamp),
252
+ }
253
+
254
+
255
+ def _matrix(query: str, result: Any) -> dict[str, JsonValue]:
256
+ series: list[dict[str, JsonValue]] = []
257
+ for item in result:
258
+ points: list[JsonValue] = [[float(ts), _number(text)] for ts, text in item["values"]]
259
+ numbers = [p[1] for p in points if isinstance(p, list) and isinstance(p[1], float)]
260
+ series.append(
261
+ {
262
+ "metric": dict(item.get("metric", {})),
263
+ "values": points,
264
+ "first": numbers[0] if numbers else None,
265
+ "last": numbers[-1] if numbers else None,
266
+ "min": min(numbers) if numbers else None,
267
+ "max": max(numbers) if numbers else None,
268
+ }
269
+ )
270
+ only = series[0] if len(series) == 1 else None
271
+ return {
272
+ "query": query,
273
+ "result_type": "matrix",
274
+ "count": len(series),
275
+ "first": only["first"] if only else None,
276
+ "last": only["last"] if only else None,
277
+ "min": only["min"] if only else None,
278
+ "max": only["max"] if only else None,
279
+ "series": cast("list[JsonValue]", series),
280
+ }
281
+
282
+
283
+ def _number(text: Any) -> float | None:
284
+ """A sample value as a float, or None for NaN and infinities, which JSON cannot hold."""
285
+ value = float(text)
286
+ return value if math.isfinite(value) else None
@@ -1,112 +0,0 @@
1
- """Kubernetes implements. Scaffold stubs: the operations are declared, not built.
2
-
3
- Real versions need the ``kubernetes`` extra: pip install "ushabti[kubernetes]"
4
- """
5
-
6
- from collections.abc import Mapping
7
- from typing import ClassVar
8
-
9
- from pydantic import JsonValue
10
-
11
- from ushabti.field import Field
12
- from ushabti.implements import Implement, ImplementKind
13
-
14
- _NAMESPACE: dict[str, JsonValue] = {
15
- "type": "string",
16
- "description": "Namespace to look in. Must be one of the field's namespaces.",
17
- }
18
- _DEPLOYMENT: dict[str, JsonValue] = {"type": "string", "description": "Name of the deployment."}
19
-
20
-
21
- class ListPods(Implement):
22
- name: ClassVar[str] = "kubernetes.list_pods"
23
- kind: ClassVar[ImplementKind] = ImplementKind.READ
24
- description: ClassVar[str] = "List the pods in a namespace with their phase and readiness."
25
- parameters: ClassVar[Mapping[str, JsonValue]] = {
26
- "type": "object",
27
- "properties": {
28
- "namespace": _NAMESPACE,
29
- "label_selector": {
30
- "type": "string",
31
- "description": "Optional label selector, such as app=web.",
32
- },
33
- },
34
- "required": ["namespace"],
35
- "additionalProperties": False,
36
- }
37
-
38
- async def _run(self, arguments: Mapping[str, JsonValue], field: Field | None) -> JsonValue:
39
- # TODO(scaffold): list pods in the field's cluster with the Kubernetes client.
40
- raise NotImplementedError(
41
- "kubernetes.list_pods is a scaffold stub: listing pods through the "
42
- "Kubernetes client (the `kubernetes` extra) is not implemented yet"
43
- )
44
-
45
-
46
- class GetDeployment(Implement):
47
- name: ClassVar[str] = "kubernetes.get_deployment"
48
- kind: ClassVar[ImplementKind] = ImplementKind.READ
49
- description: ClassVar[str] = "Get one deployment with its desired and ready replica counts."
50
- parameters: ClassVar[Mapping[str, JsonValue]] = {
51
- "type": "object",
52
- "properties": {"namespace": _NAMESPACE, "name": _DEPLOYMENT},
53
- "required": ["namespace", "name"],
54
- "additionalProperties": False,
55
- }
56
-
57
- async def _run(self, arguments: Mapping[str, JsonValue], field: Field | None) -> JsonValue:
58
- # TODO(scaffold): read the deployment and its status with the Kubernetes client.
59
- raise NotImplementedError(
60
- "kubernetes.get_deployment is a scaffold stub: reading a deployment through "
61
- "the Kubernetes client (the `kubernetes` extra) is not implemented yet"
62
- )
63
-
64
-
65
- class RestartDeployment(Implement):
66
- name: ClassVar[str] = "kubernetes.restart_deployment"
67
- kind: ClassVar[ImplementKind] = ImplementKind.WRITE
68
- description: ClassVar[str] = "Restart a deployment by rolling its pods."
69
- parameters: ClassVar[Mapping[str, JsonValue]] = {
70
- "type": "object",
71
- "properties": {"namespace": _NAMESPACE, "name": _DEPLOYMENT},
72
- "required": ["namespace", "name"],
73
- "additionalProperties": False,
74
- }
75
-
76
- async def _run(self, arguments: Mapping[str, JsonValue], field: Field | None) -> JsonValue:
77
- # TODO(scaffold): trigger a rolling restart of the deployment with the Kubernetes client.
78
- raise NotImplementedError(
79
- "kubernetes.restart_deployment is a scaffold stub: the rolling restart through "
80
- "the Kubernetes client (the `kubernetes` extra) is not implemented yet"
81
- )
82
-
83
-
84
- class ScaleDeployment(Implement):
85
- name: ClassVar[str] = "kubernetes.scale_deployment"
86
- kind: ClassVar[ImplementKind] = ImplementKind.WRITE
87
- description: ClassVar[str] = "Set the replica count of a deployment."
88
- parameters: ClassVar[Mapping[str, JsonValue]] = {
89
- "type": "object",
90
- "properties": {
91
- "namespace": _NAMESPACE,
92
- "name": _DEPLOYMENT,
93
- "replicas": {
94
- "type": "integer",
95
- "minimum": 0,
96
- "description": "Replica count to set.",
97
- },
98
- },
99
- "required": ["namespace", "name", "replicas"],
100
- "additionalProperties": False,
101
- }
102
-
103
- async def _run(self, arguments: Mapping[str, JsonValue], field: Field | None) -> JsonValue:
104
- # TODO(scaffold): set the deployment's replica count with the Kubernetes client.
105
- raise NotImplementedError(
106
- "kubernetes.scale_deployment is a scaffold stub: scaling a deployment through "
107
- "the Kubernetes client (the `kubernetes` extra) is not implemented yet"
108
- )
109
-
110
-
111
- def implements() -> tuple[Implement, ...]:
112
- return (ListPods(), GetDeployment(), RestartDeployment(), ScaleDeployment())
@@ -1,61 +0,0 @@
1
- """Prometheus implements. Scaffold stubs: the operations are declared, not built.
2
-
3
- Real versions need the ``prometheus`` extra: pip install "ushabti[prometheus]"
4
- """
5
-
6
- from collections.abc import Mapping
7
- from typing import ClassVar
8
-
9
- from pydantic import JsonValue
10
-
11
- from ushabti.field import Field
12
- from ushabti.implements import Implement, ImplementKind
13
-
14
- _QUERY: dict[str, JsonValue] = {"type": "string", "description": "PromQL expression to evaluate."}
15
-
16
-
17
- class Query(Implement):
18
- name: ClassVar[str] = "prometheus.query"
19
- kind: ClassVar[ImplementKind] = ImplementKind.READ
20
- description: ClassVar[str] = "Evaluate a PromQL expression at a single point in time."
21
- parameters: ClassVar[Mapping[str, JsonValue]] = {
22
- "type": "object",
23
- "properties": {"query": _QUERY},
24
- "required": ["query"],
25
- "additionalProperties": False,
26
- }
27
-
28
- async def _run(self, arguments: Mapping[str, JsonValue], field: Field | None) -> JsonValue:
29
- # TODO(scaffold): send an instant query to the field's Prometheus endpoint over HTTP.
30
- raise NotImplementedError(
31
- "prometheus.query is a scaffold stub: the instant query over HTTP "
32
- "(httpx, the `prometheus` extra) is not implemented yet"
33
- )
34
-
35
-
36
- class QueryRange(Implement):
37
- name: ClassVar[str] = "prometheus.query_range"
38
- kind: ClassVar[ImplementKind] = ImplementKind.READ
39
- description: ClassVar[str] = "Evaluate a PromQL expression over a time range."
40
- parameters: ClassVar[Mapping[str, JsonValue]] = {
41
- "type": "object",
42
- "properties": {
43
- "query": _QUERY,
44
- "start": {"type": "string", "description": "Start of the range, RFC 3339."},
45
- "end": {"type": "string", "description": "End of the range, RFC 3339."},
46
- "step": {"type": "string", "description": "Resolution step, such as 60s."},
47
- },
48
- "required": ["query", "start", "end", "step"],
49
- "additionalProperties": False,
50
- }
51
-
52
- async def _run(self, arguments: Mapping[str, JsonValue], field: Field | None) -> JsonValue:
53
- # TODO(scaffold): send a range query to the field's Prometheus endpoint over HTTP.
54
- raise NotImplementedError(
55
- "prometheus.query_range is a scaffold stub: the range query over HTTP "
56
- "(httpx, the `prometheus` extra) is not implemented yet"
57
- )
58
-
59
-
60
- def implements() -> tuple[Implement, ...]:
61
- return (Query(), QueryRange())
File without changes