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.
- ushabti/__init__.py +135 -0
- ushabti/_base.py +80 -0
- ushabti/config.py +129 -0
- ushabti/errors.py +58 -0
- ushabti/field.py +42 -0
- ushabti/here_am_i.py +48 -0
- ushabti/implements/__init__.py +185 -0
- ushabti/implements/kubernetes.py +112 -0
- ushabti/implements/noop.py +50 -0
- ushabti/implements/prometheus.py +61 -0
- ushabti/implements/proxmox.py +81 -0
- ushabti/maat.py +285 -0
- ushabti/models/__init__.py +175 -0
- ushabti/models/anthropic.py +187 -0
- ushabti/models/fake.py +86 -0
- ushabti/models/openai_compatible.py +190 -0
- ushabti/omen.py +36 -0
- ushabti/papyrus.py +112 -0
- ushabti/py.typed +0 -0
- ushabti/reference.py +291 -0
- ushabti/spell.py +392 -0
- ushabti/summons.py +71 -0
- ushabti/weighing.py +160 -0
- ushabti-0.1.0.dev0.dist-info/METADATA +53 -0
- ushabti-0.1.0.dev0.dist-info/RECORD +27 -0
- ushabti-0.1.0.dev0.dist-info/WHEEL +4 -0
- ushabti-0.1.0.dev0.dist-info/licenses/LICENSE +202 -0
|
@@ -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
|