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.
- {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/PKG-INFO +3 -1
- {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/README.md +2 -0
- {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/pyproject.toml +1 -1
- {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/pyproject.toml.orig +1 -1
- {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/errors.py +8 -0
- {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/field.py +9 -1
- {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/implements/__init__.py +21 -1
- ushabti-0.1.0.dev1/src/ushabti/implements/kubernetes.py +444 -0
- ushabti-0.1.0.dev1/src/ushabti/implements/prometheus.py +286 -0
- ushabti-0.1.0.dev0/src/ushabti/implements/kubernetes.py +0 -112
- ushabti-0.1.0.dev0/src/ushabti/implements/prometheus.py +0 -61
- {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/LICENSE +0 -0
- {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/__init__.py +0 -0
- {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/_base.py +0 -0
- {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/config.py +0 -0
- {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/here_am_i.py +0 -0
- {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/implements/noop.py +0 -0
- {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/implements/proxmox.py +0 -0
- {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/maat.py +0 -0
- {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/models/__init__.py +0 -0
- {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/models/anthropic.py +0 -0
- {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/models/fake.py +0 -0
- {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/models/openai_compatible.py +0 -0
- {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/omen.py +0 -0
- {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/papyrus.py +0 -0
- {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/py.typed +0 -0
- {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/reference.py +0 -0
- {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/spell.py +0 -0
- {ushabti-0.1.0.dev0 → ushabti-0.1.0.dev1}/src/ushabti/summons.py +0 -0
- {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.
|
|
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,
|
|
@@ -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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|