ushabti 0.1.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,112 @@
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())
@@ -0,0 +1,50 @@
1
+ """Implements that touch nothing. For tests and examples."""
2
+
3
+ from collections.abc import Mapping
4
+ from typing import ClassVar
5
+
6
+ from pydantic import JsonValue
7
+
8
+ from ushabti.field import Field
9
+ from ushabti.implements import Implement, ImplementKind
10
+
11
+ _ANY_ARGUMENTS: Mapping[str, JsonValue] = {"type": "object", "additionalProperties": True}
12
+
13
+
14
+ class NoopRead(Implement):
15
+ """A read implement that returns its own arguments as the observation.
16
+
17
+ That makes a Ma'at assertion testable from a spell alone: the spell decides
18
+ what the "observation" is, and no real system is involved.
19
+ """
20
+
21
+ name: ClassVar[str] = "noop.read"
22
+ kind: ClassVar[ImplementKind] = ImplementKind.READ
23
+ description: ClassVar[str] = "Return the given arguments unchanged. Observes nothing real."
24
+ parameters: ClassVar[Mapping[str, JsonValue]] = _ANY_ARGUMENTS
25
+
26
+ async def _run(self, arguments: Mapping[str, JsonValue], field: Field | None) -> JsonValue:
27
+ return dict(arguments)
28
+
29
+
30
+ class NoopWrite(Implement):
31
+ """A write implement that changes nothing and remembers each call.
32
+
33
+ ``calls`` lets a test prove that a write did or did not execute.
34
+ """
35
+
36
+ name: ClassVar[str] = "noop.write"
37
+ kind: ClassVar[ImplementKind] = ImplementKind.WRITE
38
+ description: ClassVar[str] = "Pretend to change something. Changes nothing real."
39
+ parameters: ClassVar[Mapping[str, JsonValue]] = _ANY_ARGUMENTS
40
+
41
+ def __init__(self) -> None:
42
+ self.calls: list[dict[str, JsonValue]] = []
43
+
44
+ async def _run(self, arguments: Mapping[str, JsonValue], field: Field | None) -> JsonValue:
45
+ self.calls.append(dict(arguments))
46
+ return {"ok": True, "arguments": dict(arguments)}
47
+
48
+
49
+ def implements() -> tuple[Implement, ...]:
50
+ return (NoopRead(), NoopWrite())
@@ -0,0 +1,61 @@
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())
@@ -0,0 +1,81 @@
1
+ """Proxmox implements. Scaffold stubs: the operations are declared, not built.
2
+
3
+ Real versions need the ``proxmox`` extra: pip install "ushabti[proxmox]"
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
+ _NODE: dict[str, JsonValue] = {
15
+ "type": "string",
16
+ "description": "Name of the Proxmox node. Must be one of the field's nodes.",
17
+ }
18
+
19
+
20
+ class NodeStatus(Implement):
21
+ name: ClassVar[str] = "proxmox.node_status"
22
+ kind: ClassVar[ImplementKind] = ImplementKind.READ
23
+ description: ClassVar[str] = "Get the status of a Proxmox node: uptime, CPU, memory, storage."
24
+ parameters: ClassVar[Mapping[str, JsonValue]] = {
25
+ "type": "object",
26
+ "properties": {"node": _NODE},
27
+ "required": ["node"],
28
+ "additionalProperties": False,
29
+ }
30
+
31
+ async def _run(self, arguments: Mapping[str, JsonValue], field: Field | None) -> JsonValue:
32
+ # TODO(scaffold): read the node status with the Proxmox API client.
33
+ raise NotImplementedError(
34
+ "proxmox.node_status is a scaffold stub: reading node status through "
35
+ "proxmoxer (the `proxmox` extra) is not implemented yet"
36
+ )
37
+
38
+
39
+ class ListVms(Implement):
40
+ name: ClassVar[str] = "proxmox.list_vms"
41
+ kind: ClassVar[ImplementKind] = ImplementKind.READ
42
+ description: ClassVar[str] = "List the virtual machines on a Proxmox node with their state."
43
+ parameters: ClassVar[Mapping[str, JsonValue]] = {
44
+ "type": "object",
45
+ "properties": {"node": _NODE},
46
+ "required": ["node"],
47
+ "additionalProperties": False,
48
+ }
49
+
50
+ async def _run(self, arguments: Mapping[str, JsonValue], field: Field | None) -> JsonValue:
51
+ # TODO(scaffold): list the node's virtual machines with the Proxmox API client.
52
+ raise NotImplementedError(
53
+ "proxmox.list_vms is a scaffold stub: listing virtual machines through "
54
+ "proxmoxer (the `proxmox` extra) is not implemented yet"
55
+ )
56
+
57
+
58
+ class StartVm(Implement):
59
+ name: ClassVar[str] = "proxmox.start_vm"
60
+ kind: ClassVar[ImplementKind] = ImplementKind.WRITE
61
+ description: ClassVar[str] = "Start a stopped virtual machine on a Proxmox node."
62
+ parameters: ClassVar[Mapping[str, JsonValue]] = {
63
+ "type": "object",
64
+ "properties": {
65
+ "node": _NODE,
66
+ "vmid": {"type": "integer", "description": "Numeric id of the virtual machine."},
67
+ },
68
+ "required": ["node", "vmid"],
69
+ "additionalProperties": False,
70
+ }
71
+
72
+ async def _run(self, arguments: Mapping[str, JsonValue], field: Field | None) -> JsonValue:
73
+ # TODO(scaffold): start the virtual machine with the Proxmox API client.
74
+ raise NotImplementedError(
75
+ "proxmox.start_vm is a scaffold stub: starting a virtual machine through "
76
+ "proxmoxer (the `proxmox` extra) is not implemented yet"
77
+ )
78
+
79
+
80
+ def implements() -> tuple[Implement, ...]:
81
+ return (NodeStatus(), ListVms(), StartVm())
ushabti/maat.py ADDED
@@ -0,0 +1,285 @@
1
+ """Ma'at (the desired state) and isfet (current drift from it)."""
2
+
3
+ import json
4
+ from collections.abc import Callable, Iterable, Iterator, Mapping
5
+ from datetime import datetime
6
+ from enum import StrEnum
7
+ from typing import Final, Self
8
+
9
+ import pydantic
10
+ from pydantic import JsonValue, model_validator
11
+
12
+ from ushabti._base import Name, StrictModel, WireModel, new_id, utc_now
13
+ from ushabti.omen import Omen
14
+
15
+ JsonScalar = str | int | float | bool | None
16
+
17
+ _MISSING: Final = object()
18
+
19
+
20
+ class Comparison(StrEnum):
21
+ EQ = "eq"
22
+ NE = "ne"
23
+ LT = "lt"
24
+ LE = "le"
25
+ GT = "gt"
26
+ GE = "ge"
27
+
28
+
29
+ class Condition(StrictModel):
30
+ """One comparison against an observation."""
31
+
32
+ path: str = ""
33
+ """Dotted path into the observation, such as `status.ready` or `items.0.phase`.
34
+ Empty means the whole observation."""
35
+
36
+ op: Comparison
37
+ """Comparison to apply: `eq`, `ne`, `lt`, `le`, `gt`, or `ge`."""
38
+
39
+ value: JsonScalar
40
+ """Value to compare the observation against."""
41
+
42
+ def describe(self) -> str:
43
+ """The condition in readable form, such as ``used_percent lt 90``."""
44
+ return f"{self.path or 'observation'} {self.op.value} {json.dumps(self.value)}"
45
+
46
+ def evaluate(self, observation: JsonValue) -> tuple[bool | None, object]:
47
+ """Apply the condition.
48
+
49
+ Returns the outcome and the observed value. The outcome is None when the
50
+ condition cannot be decided: the path is absent, or the two values
51
+ cannot be ordered.
52
+ """
53
+ observed = _lookup(observation, self.path)
54
+ if observed is _MISSING:
55
+ return None, None
56
+ return _compare(self.op, observed, self.value), observed
57
+
58
+
59
+ class Observe(StrictModel):
60
+ """Which read implement the vigil calls to check an assertion."""
61
+
62
+ implement: str
63
+ """Name of a read implement listed in the spell."""
64
+
65
+ arguments: dict[str, JsonValue] = pydantic.Field(default_factory=dict)
66
+ """Arguments passed to the implement."""
67
+
68
+
69
+ class MaatAssertion(StrictModel):
70
+ """One statement about what healthy looks like."""
71
+
72
+ id: Name
73
+ """Identifier of the assertion, unique within the spell."""
74
+
75
+ description: str
76
+ """What healthy looks like, in plain language. The model reads this."""
77
+
78
+ field: str | None = None
79
+ """Name of the field the assertion is about. Optional when the spell has one field."""
80
+
81
+ observe: Observe | None = None
82
+ """The observation the vigil makes each tick. Without it the assertion is
83
+ context for the model only and is never evaluated."""
84
+
85
+ expect: Condition | None = None
86
+ """Condition that must hold. When it does not, the vigil raises isfet (drift:
87
+ something is wrong now)."""
88
+
89
+ omen: Condition | None = None
90
+ """Early-warning condition. When it holds while `expect` still holds, the
91
+ vigil raises an omen (a prediction that something will be wrong soon)."""
92
+
93
+ @model_validator(mode="after")
94
+ def _check_is_complete(self) -> Self:
95
+ if self.observe is None and (self.expect is not None or self.omen is not None):
96
+ raise ValueError("expect and omen need an observe block")
97
+ if self.observe is not None and self.expect is None:
98
+ raise ValueError("observe needs an expect condition")
99
+ return self
100
+
101
+
102
+ class MaatStatus(StrEnum):
103
+ IN_BALANCE = "in_balance"
104
+ ISFET = "isfet"
105
+ OMEN = "omen"
106
+ UNKNOWN = "unknown"
107
+
108
+
109
+ class Isfet(WireModel):
110
+ """Current drift from Ma'at: something is wrong now."""
111
+
112
+ id: str
113
+ """Identifier of this drift report."""
114
+
115
+ assertion: str
116
+ """Id of the Ma'at assertion that does not hold."""
117
+
118
+ field: str | None = None
119
+ """Name of the field the drift concerns, when known."""
120
+
121
+ description: str
122
+ """What the assertion says healthy looks like."""
123
+
124
+ observed: JsonValue = None
125
+ """The observed value that broke the assertion."""
126
+
127
+ expected: str
128
+ """The condition that should have held, in readable form."""
129
+
130
+ detected_at: datetime
131
+ """When the drift was detected."""
132
+
133
+
134
+ class MaatOutcome(WireModel):
135
+ """The result of comparing one observation to one assertion."""
136
+
137
+ assertion: str
138
+ status: MaatStatus
139
+ reason: str
140
+ isfet: Isfet | None = None
141
+ omen: Omen | None = None
142
+
143
+
144
+ class Maat:
145
+ """The desired state: the assertions of one spell."""
146
+
147
+ def __init__(self, assertions: Iterable[MaatAssertion]) -> None:
148
+ self._assertions = tuple(assertions)
149
+ seen: set[str] = set()
150
+ for assertion in self._assertions:
151
+ if assertion.id in seen:
152
+ raise ValueError(f"duplicate Ma'at assertion id: {assertion.id}")
153
+ seen.add(assertion.id)
154
+
155
+ def __iter__(self) -> Iterator[MaatAssertion]:
156
+ return iter(self._assertions)
157
+
158
+ def __len__(self) -> int:
159
+ return len(self._assertions)
160
+
161
+ @property
162
+ def checkable(self) -> tuple[MaatAssertion, ...]:
163
+ """Assertions the vigil can evaluate, in spell order."""
164
+ return tuple(a for a in self._assertions if a.observe is not None)
165
+
166
+ def evaluate(
167
+ self,
168
+ assertion: MaatAssertion,
169
+ observation: JsonValue,
170
+ *,
171
+ field: str | None = None,
172
+ at: datetime | None = None,
173
+ ids: Callable[[], str] = new_id,
174
+ ) -> MaatOutcome:
175
+ """Compare an observation to an assertion and report isfet or an omen."""
176
+ if assertion.expect is None:
177
+ return MaatOutcome(
178
+ assertion=assertion.id,
179
+ status=MaatStatus.UNKNOWN,
180
+ reason="the assertion has no expect condition, so it cannot be evaluated",
181
+ )
182
+ moment = utc_now() if at is None else at
183
+ holds, observed = assertion.expect.evaluate(observation)
184
+ if holds is None:
185
+ return MaatOutcome(
186
+ assertion=assertion.id,
187
+ status=MaatStatus.UNKNOWN,
188
+ reason=f"could not evaluate: {assertion.expect.describe()}",
189
+ )
190
+ if not holds:
191
+ isfet = Isfet(
192
+ id=ids(),
193
+ assertion=assertion.id,
194
+ field=field,
195
+ description=assertion.description,
196
+ observed=_as_json(observed),
197
+ expected=assertion.expect.describe(),
198
+ detected_at=moment,
199
+ )
200
+ return MaatOutcome(
201
+ assertion=assertion.id,
202
+ status=MaatStatus.ISFET,
203
+ reason=f"expected {isfet.expected}, observed {json.dumps(isfet.observed)}",
204
+ isfet=isfet,
205
+ )
206
+ if assertion.omen is not None:
207
+ warned, seen = assertion.omen.evaluate(observation)
208
+ if warned:
209
+ omen = Omen(
210
+ id=ids(),
211
+ assertion=assertion.id,
212
+ field=field,
213
+ description=assertion.description,
214
+ observed=_as_json(seen),
215
+ warning=assertion.omen.describe(),
216
+ predicted_at=moment,
217
+ )
218
+ return MaatOutcome(
219
+ assertion=assertion.id,
220
+ status=MaatStatus.OMEN,
221
+ reason=f"warning met: {omen.warning}, observed {json.dumps(omen.observed)}",
222
+ omen=omen,
223
+ )
224
+ return MaatOutcome(
225
+ assertion=assertion.id,
226
+ status=MaatStatus.IN_BALANCE,
227
+ reason=f"holds: {assertion.expect.describe()}",
228
+ )
229
+
230
+
231
+ def _lookup(data: JsonValue, path: str) -> object:
232
+ current: object = data
233
+ if not path:
234
+ return current
235
+ for part in path.split("."):
236
+ if isinstance(current, Mapping) and part in current:
237
+ current = current[part]
238
+ elif isinstance(current, list) and part.isdigit() and int(part) < len(current):
239
+ current = current[int(part)]
240
+ else:
241
+ return _MISSING
242
+ return current
243
+
244
+
245
+ def _is_number(value: object) -> bool:
246
+ return isinstance(value, int | float) and not isinstance(value, bool)
247
+
248
+
249
+ def _equal(left: object, right: object) -> bool:
250
+ if _is_number(left) and _is_number(right):
251
+ return left == right
252
+ if isinstance(left, bool) or isinstance(right, bool):
253
+ # Python treats True as 1. A health flag must never equal a count.
254
+ return isinstance(left, bool) and isinstance(right, bool) and left == right
255
+ return left == right
256
+
257
+
258
+ def _compare(op: Comparison, observed: object, expected: JsonScalar) -> bool | None:
259
+ if op is Comparison.EQ:
260
+ return _equal(observed, expected)
261
+ if op is Comparison.NE:
262
+ return not _equal(observed, expected)
263
+ if isinstance(observed, bool) or isinstance(expected, bool):
264
+ return None
265
+ if isinstance(observed, int | float) and isinstance(expected, int | float):
266
+ return _order(op, float(observed), float(expected))
267
+ if isinstance(observed, str) and isinstance(expected, str):
268
+ return _order(op, observed, expected)
269
+ return None
270
+
271
+
272
+ def _order[T: (float, str)](op: Comparison, left: T, right: T) -> bool:
273
+ if op is Comparison.LT:
274
+ return left < right
275
+ if op is Comparison.LE:
276
+ return left <= right
277
+ if op is Comparison.GT:
278
+ return left > right
279
+ return left >= right
280
+
281
+
282
+ def _as_json(value: object) -> JsonValue:
283
+ """Round-trip through JSON so the value is safe to store in a model."""
284
+ result: JsonValue = json.loads(json.dumps(value, default=str))
285
+ return result