tallwright-cloud 1.0.0rc1__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.
- tallwright_cloud/__init__.py +85 -0
- tallwright_cloud/account.py +254 -0
- tallwright_cloud/cli.py +333 -0
- tallwright_cloud/client.py +894 -0
- tallwright_cloud/commit.py +111 -0
- tallwright_cloud/environments.py +116 -0
- tallwright_cloud/errors.py +143 -0
- tallwright_cloud/evidence.py +458 -0
- tallwright_cloud/exhibit.py +216 -0
- tallwright_cloud/ids.py +147 -0
- tallwright_cloud/py.typed +0 -0
- tallwright_cloud/receipt.py +111 -0
- tallwright_cloud/sender.py +257 -0
- tallwright_cloud/store.py +468 -0
- tallwright_cloud/transport.py +255 -0
- tallwright_cloud-1.0.0rc1.dist-info/METADATA +241 -0
- tallwright_cloud-1.0.0rc1.dist-info/RECORD +21 -0
- tallwright_cloud-1.0.0rc1.dist-info/WHEEL +4 -0
- tallwright_cloud-1.0.0rc1.dist-info/entry_points.txt +2 -0
- tallwright_cloud-1.0.0rc1.dist-info/licenses/LICENSE +202 -0
- tallwright_cloud-1.0.0rc1.dist-info/licenses/NOTICE +5 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""tallwright_cloud — Cloud Proof from Python, with the content kept where it is.
|
|
3
|
+
|
|
4
|
+
from tallwright_cloud import Tallwright
|
|
5
|
+
|
|
6
|
+
client = Tallwright(ingest_url, archive_key, "./tallwright-evidence")
|
|
7
|
+
client.submit("what the agent was told") # context it received
|
|
8
|
+
client.submit({"slot": "2026-11-03T09:00"}, action_type="appointment.booked") # an action it took
|
|
9
|
+
|
|
10
|
+
This package is the recorder: it commits, keeps the evidence, sends, sends again and reads the
|
|
11
|
+
receipt, and nothing built on it does any of that again (https://docs.tallwright.com/adapters). The content
|
|
12
|
+
never leaves the caller's machine: a salted address of it does, and the pair that opens the address is
|
|
13
|
+
kept in the caller's evidence store, in the form the public verifier reads. What the door answers is a
|
|
14
|
+
receipt — a confirmation, not a proof; the proof is the exhibit made once a witness has seen the log,
|
|
15
|
+
checked with the disclosure kept here.
|
|
16
|
+
|
|
17
|
+
- :class:`Tallwright` records into one Environment: ``capture`` waits for the receipt, ``submit``
|
|
18
|
+
only for the evidence (``client.py``, ``sender.py``);
|
|
19
|
+
- :class:`Environments` gives each end customer its own client, evidence and key (``environments.py``);
|
|
20
|
+
- :class:`LocalEvidence` and :class:`S3Evidence` keep the evidence (``store.py``);
|
|
21
|
+
- :class:`Account` makes and keeps the Environments themselves, with an account key (``account.py``);
|
|
22
|
+
- :class:`Exhibits` takes the exhibit of a record, with an export key;
|
|
23
|
+
- ``tallwright-cloud`` is the command line (``cli.py``).
|
|
24
|
+
|
|
25
|
+
See https://docs.tallwright.com/http for the same contract over plain HTTP, and
|
|
26
|
+
https://docs.tallwright.com/python/guarantees for what 1.0 promises and the tests that hold it to that.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from .account import Account, AccountError, Environment, EnvironmentExists, Made, Secret
|
|
32
|
+
from .client import Capture, Exhibits, Status, Tallwright
|
|
33
|
+
from .environments import Environments
|
|
34
|
+
from .errors import (
|
|
35
|
+
AccountClosed,
|
|
36
|
+
EvidenceError,
|
|
37
|
+
ExhibitError,
|
|
38
|
+
ExhibitNotReady,
|
|
39
|
+
ExportKeyRefused,
|
|
40
|
+
LinkSpent,
|
|
41
|
+
LostByTheDoor,
|
|
42
|
+
NotConfirmed,
|
|
43
|
+
RecordIdConflict,
|
|
44
|
+
Refused,
|
|
45
|
+
TallwrightError,
|
|
46
|
+
Unauthorized,
|
|
47
|
+
)
|
|
48
|
+
from .ids import gaps
|
|
49
|
+
from .receipt import Receipt
|
|
50
|
+
from .sender import Flushed
|
|
51
|
+
from .store import EvidenceStore, LocalEvidence, S3Evidence
|
|
52
|
+
|
|
53
|
+
__version__ = "1.0.0rc1"
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"Tallwright",
|
|
57
|
+
"Environments",
|
|
58
|
+
"Exhibits",
|
|
59
|
+
"Account",
|
|
60
|
+
"Capture",
|
|
61
|
+
"Receipt",
|
|
62
|
+
"Status",
|
|
63
|
+
"Flushed",
|
|
64
|
+
"EvidenceStore",
|
|
65
|
+
"LocalEvidence",
|
|
66
|
+
"S3Evidence",
|
|
67
|
+
"Environment",
|
|
68
|
+
"Made",
|
|
69
|
+
"Secret",
|
|
70
|
+
"gaps",
|
|
71
|
+
"TallwrightError",
|
|
72
|
+
"NotConfirmed",
|
|
73
|
+
"Unauthorized",
|
|
74
|
+
"AccountClosed",
|
|
75
|
+
"Refused",
|
|
76
|
+
"RecordIdConflict",
|
|
77
|
+
"LostByTheDoor",
|
|
78
|
+
"EvidenceError",
|
|
79
|
+
"ExhibitError",
|
|
80
|
+
"ExhibitNotReady",
|
|
81
|
+
"LinkSpent",
|
|
82
|
+
"ExportKeyRefused",
|
|
83
|
+
"AccountError",
|
|
84
|
+
"EnvironmentExists",
|
|
85
|
+
]
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""An account's Environments, made and kept by the vendor's own system (``/v1/environments``).
|
|
3
|
+
|
|
4
|
+
account = Account(app_url, account_key)
|
|
5
|
+
made = account.create("Bäckerei Müller", customer_ref="4711", deployer_label="acme-voice")
|
|
6
|
+
store_secret("4711", made.key.reveal()) # shown once, here and nowhere else
|
|
7
|
+
|
|
8
|
+
The account key (``ta_…``) is made by an Owner or Admin on the Settings screen and can do what a person
|
|
9
|
+
on the Environments screen can: make an Environment, list and find them, replace a recording key,
|
|
10
|
+
close one. It is NOT the key an agent records with, and it never belongs in the process that records:
|
|
11
|
+
that process holds one recording key per customer and nothing that makes more.
|
|
12
|
+
|
|
13
|
+
**A create is safe to repeat, and this package always sends one that is.** ``customer_ref`` is
|
|
14
|
+
required here, though the API takes a create without one: with it, a create the door already did is
|
|
15
|
+
answered 409 with the Environment it made, and nothing is made twice. Without it, an answer lost on
|
|
16
|
+
the way back would leave an Environment nobody knows about. What a lost answer still costs is the key,
|
|
17
|
+
which is shown once: :class:`EnvironmentExists` says whether an earlier attempt of THIS call may have
|
|
18
|
+
made it, and then :meth:`Account.new_key` gives the Environment a key somebody holds.
|
|
19
|
+
|
|
20
|
+
**Keys come back wrapped.** A recording key in a :class:`Secret`, whose repr shows its face and no
|
|
21
|
+
more, so a log line or an error tracker that records the object records no key.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import http.client
|
|
27
|
+
import json
|
|
28
|
+
import time
|
|
29
|
+
from collections.abc import Iterator
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from typing import Any
|
|
32
|
+
from urllib.parse import quote, urlencode
|
|
33
|
+
|
|
34
|
+
from . import transport
|
|
35
|
+
from .errors import TallwrightError
|
|
36
|
+
from .ids import checked_ref
|
|
37
|
+
|
|
38
|
+
#: The answers that decide nothing: the same request is sent again, after the same waits as a capture.
|
|
39
|
+
RETRY = frozenset({408, 425, 429, 500, 502, 503, 504})
|
|
40
|
+
DELAYS = (0.5, 1.0, 2.0)
|
|
41
|
+
_pause = time.sleep
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AccountError(TallwrightError):
|
|
45
|
+
"""The account API refused or could not complete a request, in its own sentence where it gave one."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class EnvironmentExists(AccountError):
|
|
49
|
+
"""409: an open Environment for this ``customer_ref`` exists, and nothing was made."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, message: str, environment: Environment, *, maybe_ours: bool) -> None:
|
|
52
|
+
super().__init__(message, status=409)
|
|
53
|
+
self.environment = environment
|
|
54
|
+
#: True when an earlier attempt of the same call may have made it, and its key was lost with
|
|
55
|
+
#: the answer: then ``new_key`` gives it one somebody holds.
|
|
56
|
+
self.maybe_ours = maybe_ours
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Secret:
|
|
60
|
+
"""A key shown once. ``reveal`` it into the secret store; nothing else shows more than its face."""
|
|
61
|
+
|
|
62
|
+
__slots__ = ("_value",)
|
|
63
|
+
|
|
64
|
+
def __init__(self, value: str) -> None:
|
|
65
|
+
self._value = transport.Key(value).reveal()
|
|
66
|
+
|
|
67
|
+
def reveal(self) -> str:
|
|
68
|
+
"""The key itself, for the one place it is handed to; everything else is shown its first characters."""
|
|
69
|
+
return self._value
|
|
70
|
+
|
|
71
|
+
def __repr__(self) -> str:
|
|
72
|
+
return f"Secret({self._value[:11]}…)"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class Environment:
|
|
77
|
+
"""One Environment as the account API shows it."""
|
|
78
|
+
|
|
79
|
+
id: str
|
|
80
|
+
name: str
|
|
81
|
+
customer_ref: str | None
|
|
82
|
+
deployer_label: str
|
|
83
|
+
environment_id: str | None
|
|
84
|
+
status: str
|
|
85
|
+
reason: str | None = None
|
|
86
|
+
created_at: str | None = None
|
|
87
|
+
closed_at: str | None = None
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def read(cls, given: object) -> Environment:
|
|
91
|
+
"""An Environment from one entry of the account API's answer; :class:`AccountError` when it is not one."""
|
|
92
|
+
if not isinstance(given, dict) or not isinstance(given.get("id"), str) or not isinstance(given.get("status"), str):
|
|
93
|
+
raise AccountError("the account API answered with something that is not an Environment")
|
|
94
|
+
def text(key: str) -> str | None:
|
|
95
|
+
value = given.get(key)
|
|
96
|
+
return value if isinstance(value, str) else None
|
|
97
|
+
|
|
98
|
+
return cls(
|
|
99
|
+
id=given["id"],
|
|
100
|
+
name=text("name") or "",
|
|
101
|
+
customer_ref=text("customer_ref"),
|
|
102
|
+
deployer_label=text("deployer_label") or "",
|
|
103
|
+
environment_id=text("environment_id"),
|
|
104
|
+
status=given["status"],
|
|
105
|
+
reason=text("reason"),
|
|
106
|
+
created_at=text("created_at"),
|
|
107
|
+
closed_at=text("closed_at"),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True)
|
|
112
|
+
class Made:
|
|
113
|
+
"""An Environment just made, or given a new key: the key is here once, and nowhere else."""
|
|
114
|
+
|
|
115
|
+
environment: Environment
|
|
116
|
+
ingest_url: str
|
|
117
|
+
key: Secret
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class Account:
|
|
121
|
+
"""The account API of one account, with its account key: it makes, finds, re-keys and closes Environments.
|
|
122
|
+
|
|
123
|
+
The account key never belongs in the process that records; that holds one recording key per customer.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def __init__(self, url: str, account_key: str, *, timeout: float = 90.0) -> None:
|
|
127
|
+
transport.checked_address(url, "the account API")
|
|
128
|
+
self.url = url.rstrip("/")
|
|
129
|
+
self._key = transport.Key(account_key)
|
|
130
|
+
self._timeout = timeout
|
|
131
|
+
|
|
132
|
+
def __repr__(self) -> str:
|
|
133
|
+
return f"Account(url={self.url!r}, account_key={self._key!r})"
|
|
134
|
+
|
|
135
|
+
def _call(self, method: str, path: str, body: dict[str, Any] | None = None) -> tuple[int, dict[str, Any], int]:
|
|
136
|
+
"""The status, the JSON body and how many attempts it took; undecided answers are tried again."""
|
|
137
|
+
# A POST without a body still says so: Content-Length 0, which no proxy has to guess at.
|
|
138
|
+
data = (b"" if method == "POST" else None) if body is None else json.dumps(body).encode("utf-8")
|
|
139
|
+
kind = None if body is None else "application/json"
|
|
140
|
+
last = ""
|
|
141
|
+
attempts = 0
|
|
142
|
+
for wait in (0.0, *DELAYS):
|
|
143
|
+
if wait:
|
|
144
|
+
_pause(wait)
|
|
145
|
+
attempts += 1
|
|
146
|
+
try:
|
|
147
|
+
status, raw, _ = transport.exchange(
|
|
148
|
+
method, self.url + path, self._key, data, kind, self._timeout, transport.MAX_ANSWER
|
|
149
|
+
)
|
|
150
|
+
except (OSError, http.client.HTTPException) as failed:
|
|
151
|
+
last = f"{type(failed).__name__}: {getattr(failed, 'reason', failed)}"
|
|
152
|
+
continue
|
|
153
|
+
# Read as the door's answers are: an answer nested past any stack is no answer, not a crash.
|
|
154
|
+
answer = transport.object_in(raw) or {}
|
|
155
|
+
if status in RETRY:
|
|
156
|
+
last = f"it answered {status} ({answer.get('error') or 'no sentence'})"
|
|
157
|
+
continue
|
|
158
|
+
return status, answer, attempts
|
|
159
|
+
raise AccountError(f"{method} {path}: {attempts} attempts, the last: {last}. Nothing is known to have been done")
|
|
160
|
+
|
|
161
|
+
def _refused(self, method: str, path: str, status: int, answer: dict[str, Any]) -> AccountError:
|
|
162
|
+
said = answer.get("error")
|
|
163
|
+
return AccountError(f"{method} {path}: {status} ({said if isinstance(said, str) else 'without the API sentence'})", status=status)
|
|
164
|
+
|
|
165
|
+
def create(self, name: str, *, customer_ref: str, deployer_label: str) -> Made:
|
|
166
|
+
"""Make an Environment for one end customer; its key is in the answer, once.
|
|
167
|
+
|
|
168
|
+
``customer_ref`` is the one :class:`~tallwright_cloud.environments.Environments` keeps the
|
|
169
|
+
customer's evidence beneath, so it is held to the same rule (``ids.checked_ref``) here, and a
|
|
170
|
+
ref nothing could record under is refused before anything is made.
|
|
171
|
+
"""
|
|
172
|
+
if not isinstance(customer_ref, str) or not customer_ref.strip():
|
|
173
|
+
raise ValueError("customer_ref is required: it is what makes a repeated create safe")
|
|
174
|
+
checked_ref(customer_ref)
|
|
175
|
+
body = {"name": name, "customer_ref": customer_ref, "deployer_label": deployer_label}
|
|
176
|
+
status, answer, attempts = self._call("POST", "/v1/environments", body)
|
|
177
|
+
if status == 409 and "environment" in answer:
|
|
178
|
+
existing = Environment.read(answer["environment"])
|
|
179
|
+
raise EnvironmentExists(
|
|
180
|
+
f"an Environment for customer_ref {customer_ref!r} exists already ({existing.status}); "
|
|
181
|
+
"nothing was made"
|
|
182
|
+
+ (
|
|
183
|
+
". An earlier attempt of this call may have made it and lost the answer, and with it "
|
|
184
|
+
"the key: new_key(...) gives it a key you then hold"
|
|
185
|
+
if attempts > 1
|
|
186
|
+
else ""
|
|
187
|
+
),
|
|
188
|
+
existing,
|
|
189
|
+
maybe_ours=attempts > 1,
|
|
190
|
+
)
|
|
191
|
+
if status != 201:
|
|
192
|
+
raise self._refused("POST", "/v1/environments", status, answer)
|
|
193
|
+
return self._made(answer)
|
|
194
|
+
|
|
195
|
+
def _made(self, answer: dict[str, Any]) -> Made:
|
|
196
|
+
key, ingest = answer.get("key"), answer.get("ingest_url")
|
|
197
|
+
if not isinstance(key, str) or not isinstance(ingest, str):
|
|
198
|
+
raise AccountError("the account API answered without the key or the ingest address")
|
|
199
|
+
environment = answer.get("environment") or {}
|
|
200
|
+
if isinstance(environment, dict) and "status" not in environment:
|
|
201
|
+
environment = {**environment, "status": "live"}
|
|
202
|
+
return Made(Environment.read(environment), ingest, Secret(key))
|
|
203
|
+
|
|
204
|
+
def environments(self, *, status: str | None = None, customer_ref: str | None = None) -> Iterator[Environment]:
|
|
205
|
+
"""Every Environment of the account, page by page, in the order the API keeps them.
|
|
206
|
+
|
|
207
|
+
A page goes on only to an offset past its own: a ``next_offset`` that repeats it, goes back or is
|
|
208
|
+
no number is an :class:`AccountError`, never a loop that asks forever or a listing cut short.
|
|
209
|
+
"""
|
|
210
|
+
offset = 0
|
|
211
|
+
while True:
|
|
212
|
+
query: dict[str, str] = {"limit": "200", "offset": str(offset)}
|
|
213
|
+
if status is not None:
|
|
214
|
+
query["status"] = status
|
|
215
|
+
if customer_ref is not None:
|
|
216
|
+
query["customer_ref"] = customer_ref
|
|
217
|
+
path = "/v1/environments?" + urlencode(query)
|
|
218
|
+
code, answer, _ = self._call("GET", path)
|
|
219
|
+
if code != 200:
|
|
220
|
+
raise self._refused("GET", "/v1/environments", code, answer)
|
|
221
|
+
for given in answer.get("environments") or []:
|
|
222
|
+
yield Environment.read(given)
|
|
223
|
+
following = answer.get("next_offset")
|
|
224
|
+
if following is None:
|
|
225
|
+
return
|
|
226
|
+
if not isinstance(following, int) or isinstance(following, bool) or following <= offset:
|
|
227
|
+
raise AccountError(
|
|
228
|
+
f"GET /v1/environments: the API answered next_offset {repr(following)[:40]} to offset "
|
|
229
|
+
f"{offset}, which does not move forward, so the listing stops here instead of asking again"
|
|
230
|
+
)
|
|
231
|
+
offset = following
|
|
232
|
+
|
|
233
|
+
def find(self, customer_ref: str) -> Environment | None:
|
|
234
|
+
"""The open Environment of ``customer_ref``, or None."""
|
|
235
|
+
for found in self.environments(customer_ref=customer_ref):
|
|
236
|
+
if found.status != "closed":
|
|
237
|
+
return found
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
def new_key(self, environment: str) -> Made:
|
|
241
|
+
"""Replace an Environment's recording key: the old one stops at once, the new one is here once."""
|
|
242
|
+
path = f"/v1/environments/{quote(environment, safe='')}/key"
|
|
243
|
+
status, answer, _ = self._call("POST", path)
|
|
244
|
+
if status != 200:
|
|
245
|
+
raise self._refused("POST", path, status, answer)
|
|
246
|
+
return self._made(answer)
|
|
247
|
+
|
|
248
|
+
def close(self, environment: str) -> Environment:
|
|
249
|
+
"""Close an Environment when its customer leaves. Its log stays and still exports."""
|
|
250
|
+
path = f"/v1/environments/{quote(environment, safe='')}/close"
|
|
251
|
+
status, answer, _ = self._call("POST", path)
|
|
252
|
+
if status != 200:
|
|
253
|
+
raise self._refused("POST", path, status, answer)
|
|
254
|
+
return Environment.read(answer.get("environment") or {})
|
tallwright_cloud/cli.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""``tallwright-cloud``: look at the evidence and the door from a shell, and act on what is pending.
|
|
3
|
+
|
|
4
|
+
tallwright-cloud doctor what stands between this machine and evidence somebody can check
|
|
5
|
+
tallwright-cloud pending records sent and never confirmed or refused
|
|
6
|
+
tallwright-cloud resend [ID ...] send pending records again, byte for byte (all of this key's when none is named)
|
|
7
|
+
tallwright-cloud gaps numbers missing from a session's sequence
|
|
8
|
+
tallwright-cloud export ID DIR copy one record's evidence into a folder, for the verifier
|
|
9
|
+
|
|
10
|
+
The ingest address, the evidence and the key come from ``TALLWRIGHT_INGEST``, ``TALLWRIGHT_EVIDENCE``
|
|
11
|
+
and ``TALLWRIGHT_KEY``, or ``--ingest`` and ``--evidence``. The key is never an argument: an argument is
|
|
12
|
+
visible to every user of the machine in its process list. Evidence in a bucket is
|
|
13
|
+
``s3://bucket/prefix``, read with boto3 and its usual configuration, when boto3 is installed.
|
|
14
|
+
|
|
15
|
+
The commands read the evidence a client kept and make none: a directory that is not there is said to
|
|
16
|
+
be missing, never created and reported empty. One Environment's evidence is what they read; the store
|
|
17
|
+
``Environments`` writes into holds each customer's beneath its ``customer_ref``, and is said to be one.
|
|
18
|
+
|
|
19
|
+
Exit codes: 0 nothing stands in the way; 1 something does, and the output says what; 2 the command
|
|
20
|
+
itself was wrong.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import os
|
|
27
|
+
import sys
|
|
28
|
+
from collections.abc import Sequence
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from . import transport
|
|
33
|
+
from .client import Tallwright
|
|
34
|
+
from .errors import TallwrightError
|
|
35
|
+
from .evidence import DISCLOSURE, LINE, RECEIPT, REFUSED, Evidence
|
|
36
|
+
from .ids import checked_record_id, gaps
|
|
37
|
+
from .store import EvidenceStore, LocalEvidence, S3Evidence, _exclusive, walk
|
|
38
|
+
|
|
39
|
+
#: The interpreters this package is tested on, in order: its classifiers, and the CI's.
|
|
40
|
+
SUPPORTED = ((3, 11), (3, 12), (3, 13))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _store(where: str | None, *, ephemeral_ok: bool = False) -> EvidenceStore:
|
|
44
|
+
"""The evidence at ``where``, opened as it is: a directory that is not there is not made."""
|
|
45
|
+
if not where:
|
|
46
|
+
raise SystemExit("tallwright-cloud: no evidence: --evidence or TALLWRIGHT_EVIDENCE")
|
|
47
|
+
if where.startswith("s3://"):
|
|
48
|
+
bucket, _, prefix = where[len("s3://") :].partition("/")
|
|
49
|
+
try:
|
|
50
|
+
import boto3 # noqa: PLC0415 - optional, the integrator's
|
|
51
|
+
except ImportError:
|
|
52
|
+
raise SystemExit("tallwright-cloud: evidence in a bucket is read with boto3, which is not installed") from None
|
|
53
|
+
return S3Evidence(boto3.client("s3"), bucket, prefix=prefix)
|
|
54
|
+
if not Path(where).is_dir():
|
|
55
|
+
raise FileNotFoundError(
|
|
56
|
+
f"no evidence directory at {where}: a client makes it when it is made with this path, and these "
|
|
57
|
+
"commands only read what one kept. Is it the path the client was given?"
|
|
58
|
+
)
|
|
59
|
+
return LocalEvidence(where, ephemeral_ok=ephemeral_ok)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _is_record(store: EvidenceStore, name: str) -> bool:
|
|
63
|
+
"""Whether ``name`` is a record's folder: the SDK writes a disclosure and a line into nothing else."""
|
|
64
|
+
try:
|
|
65
|
+
return store.read(f"{name}/{DISCLOSURE}") is not None or store.read(f"{name}/{LINE}") is not None
|
|
66
|
+
except ValueError: # not a name a record could be kept under
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _a_customer(store: EvidenceStore) -> str | None:
|
|
71
|
+
"""A ``customer_ref`` whose evidence lies in ``store``, when the store holds customers and not records.
|
|
72
|
+
|
|
73
|
+
The first folder at the top that is either decides: a record, and this is one Environment's
|
|
74
|
+
evidence; a folder whose first entry is a record, and this is the store ``Environments`` writes
|
|
75
|
+
into, one folder per customer. Anything else is looked past.
|
|
76
|
+
"""
|
|
77
|
+
for top in Evidence(store).records():
|
|
78
|
+
if _is_record(store, top):
|
|
79
|
+
return None
|
|
80
|
+
inside = [name for name in store.children(top) if not name.startswith(".")]
|
|
81
|
+
if inside and _is_record(store, f"{top}/{inside[0]}"):
|
|
82
|
+
return top
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _records(store: EvidenceStore) -> Evidence:
|
|
87
|
+
"""One Environment's evidence in ``store``; refused, with the folder to name instead, for every customer's."""
|
|
88
|
+
customer = _a_customer(store)
|
|
89
|
+
if customer is not None:
|
|
90
|
+
raise ValueError(
|
|
91
|
+
f"{store.locate('')} holds each customer's evidence beneath its customer_ref, not records: "
|
|
92
|
+
f"one customer's is --evidence {store.locate(customer)}"
|
|
93
|
+
)
|
|
94
|
+
return Evidence(store)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _key() -> str:
|
|
98
|
+
key = os.environ.get("TALLWRIGHT_KEY", "")
|
|
99
|
+
if not key:
|
|
100
|
+
raise SystemExit("tallwright-cloud: no key: set TALLWRIGHT_KEY (never an argument)")
|
|
101
|
+
return key
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _client(args: argparse.Namespace) -> Tallwright:
|
|
105
|
+
if not args.ingest:
|
|
106
|
+
raise SystemExit("tallwright-cloud: no ingest address: --ingest or TALLWRIGHT_INGEST")
|
|
107
|
+
return Tallwright(args.ingest, _key(), _records(_store(args.evidence, ephemeral_ok=args.ephemeral_ok)).store)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _say(line: str) -> None:
|
|
111
|
+
print(line) # noqa: T201
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def doctor(args: argparse.Namespace) -> int:
|
|
115
|
+
trouble = 0
|
|
116
|
+
running = f"{sys.version_info.major}.{sys.version_info.minor}"
|
|
117
|
+
if (sys.version_info.major, sys.version_info.minor) in SUPPORTED:
|
|
118
|
+
_say(f"ok Python {running}")
|
|
119
|
+
else: # a note, not a problem: the checks below run on this interpreter and say whether it is in the way
|
|
120
|
+
lowest, highest = (".".join(map(str, version)) for version in (SUPPORTED[0], SUPPORTED[-1]))
|
|
121
|
+
_say(f"note Python {running}: this package is tested on {lowest} to {highest}, not on this one")
|
|
122
|
+
try:
|
|
123
|
+
store = _store(args.evidence, ephemeral_ok=args.ephemeral_ok)
|
|
124
|
+
problems = store.check()
|
|
125
|
+
except (OSError, ValueError) as failed:
|
|
126
|
+
problems = [str(failed)]
|
|
127
|
+
store = None
|
|
128
|
+
for problem in problems:
|
|
129
|
+
trouble += 1
|
|
130
|
+
_say(f"PROBLEM evidence: {problem}")
|
|
131
|
+
if store is not None and not problems:
|
|
132
|
+
_say(f"ok evidence at {store.locate('')} keeps what it is given and never replaces it")
|
|
133
|
+
customer = _a_customer(store)
|
|
134
|
+
if customer is not None:
|
|
135
|
+
_say(
|
|
136
|
+
f"note {store.locate('')} holds each customer's evidence beneath its customer_ref: what "
|
|
137
|
+
f"is pending is counted for one customer, with --evidence {store.locate(customer)}"
|
|
138
|
+
)
|
|
139
|
+
else:
|
|
140
|
+
pending = Evidence(store).pending()
|
|
141
|
+
_say(f"{'ok ' if not pending else 'note '} {len(pending)} record(s) pending in the evidence")
|
|
142
|
+
if not args.ingest:
|
|
143
|
+
trouble += 1
|
|
144
|
+
_say("PROBLEM no ingest address: --ingest or TALLWRIGHT_INGEST")
|
|
145
|
+
return 1
|
|
146
|
+
try:
|
|
147
|
+
endpoint = transport.endpoint(args.ingest)
|
|
148
|
+
except ValueError as wrong:
|
|
149
|
+
_say(f"PROBLEM ingest address: {wrong}")
|
|
150
|
+
return 1
|
|
151
|
+
try:
|
|
152
|
+
status, _ = transport.fetch(args.ingest.rstrip("/") + "/healthz", args.timeout, 4096)
|
|
153
|
+
except OSError as failed:
|
|
154
|
+
trouble += 1
|
|
155
|
+
_say(f"PROBLEM the door at {args.ingest} cannot be reached: {type(failed).__name__}: {getattr(failed, 'reason', failed)}")
|
|
156
|
+
return 1
|
|
157
|
+
if status == 200:
|
|
158
|
+
_say(f"ok the door at {args.ingest} answers, over a certificate this machine trusts")
|
|
159
|
+
else:
|
|
160
|
+
trouble += 1
|
|
161
|
+
_say(f"PROBLEM the door at {args.ingest} answered its liveness probe with {status}")
|
|
162
|
+
if args.check_key:
|
|
163
|
+
try:
|
|
164
|
+
answer = transport.post(endpoint, transport.Key(_key()), b"", args.timeout)
|
|
165
|
+
except (OSError, ValueError) as failed:
|
|
166
|
+
trouble += 1
|
|
167
|
+
_say(f"PROBLEM the key could not be checked: {type(failed).__name__}: {getattr(failed, 'reason', failed)}")
|
|
168
|
+
else:
|
|
169
|
+
if answer.status == 200:
|
|
170
|
+
_say("ok the door holds this key (an empty batch was sent: nothing was recorded)")
|
|
171
|
+
else:
|
|
172
|
+
trouble += 1
|
|
173
|
+
_say(f"PROBLEM the door answered an empty batch with {answer.status}: {transport.said(answer) or 'no sentence'}")
|
|
174
|
+
return 1 if trouble else 0
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def pending(args: argparse.Namespace) -> int:
|
|
178
|
+
evidence = _records(_store(args.evidence, ephemeral_ok=args.ephemeral_ok))
|
|
179
|
+
names = evidence.pending() if args.all_keys or not os.environ.get("TALLWRIGHT_KEY") else evidence.pending(transport.Key(_key()).binding())
|
|
180
|
+
for name in names:
|
|
181
|
+
_say(name)
|
|
182
|
+
return 0
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def resend(args: argparse.Namespace) -> int:
|
|
186
|
+
if args.adopt and not args.record_ids:
|
|
187
|
+
_say(
|
|
188
|
+
"tallwright-cloud resend --adopt: name each record to send with this key. Whether a record "
|
|
189
|
+
"pending under another key is this Environment's is yours to know, record by record; "
|
|
190
|
+
"tallwright-cloud pending --all-keys lists them"
|
|
191
|
+
)
|
|
192
|
+
return 2
|
|
193
|
+
client = _client(args)
|
|
194
|
+
names = args.record_ids or client.pending()
|
|
195
|
+
others = [] if args.record_ids else sorted(set(Evidence(client.evidence).pending()) - set(names))
|
|
196
|
+
failed = 0
|
|
197
|
+
for name in names:
|
|
198
|
+
try:
|
|
199
|
+
captured = client.resend(name, adopt=args.adopt)
|
|
200
|
+
except TallwrightError as problem:
|
|
201
|
+
failed += 1
|
|
202
|
+
_say(f"{name}: {type(problem).__name__}: {problem}")
|
|
203
|
+
else:
|
|
204
|
+
_say(f"{name}: {captured.receipt.status} at leaf {captured.receipt.leaf_index}")
|
|
205
|
+
if not names:
|
|
206
|
+
_say("nothing pending under this key")
|
|
207
|
+
if others:
|
|
208
|
+
_say(
|
|
209
|
+
f"{len(others)} record(s) pending under another key, not sent with this one: tallwright-cloud "
|
|
210
|
+
"pending --all-keys lists them, and if this key replaced that one for the same Environment, "
|
|
211
|
+
"tallwright-cloud resend --adopt ID sends each"
|
|
212
|
+
)
|
|
213
|
+
return 1 if failed or others else 0
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _short_of_the_log(store: EvidenceStore, name: str) -> str:
|
|
217
|
+
"""Why the log is not shown to hold a record kept here, or "" when its receipt is kept."""
|
|
218
|
+
if store.read(f"{name}/{RECEIPT}") is not None:
|
|
219
|
+
return ""
|
|
220
|
+
if store.read(f"{name}/{REFUSED}") is not None:
|
|
221
|
+
return "refused"
|
|
222
|
+
return "pending" if store.read(f"{name}/{LINE}") is not None else "never sent"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def show_gaps(args: argparse.Namespace) -> int:
|
|
226
|
+
# A hole is a capture that was numbered and is not in the log. What shows a record in the log here
|
|
227
|
+
# is its receipt, so a number is a hole when no record of it is kept, and when the record kept was
|
|
228
|
+
# refused, is pending or was never sent; each is said as what it is.
|
|
229
|
+
evidence = _records(_store(args.evidence, ephemeral_ok=args.ephemeral_ok))
|
|
230
|
+
kept = evidence.records()
|
|
231
|
+
found = {session: dict.fromkeys(missing, "") for session, missing in gaps(kept).items()}
|
|
232
|
+
for name in kept:
|
|
233
|
+
for session in gaps([name]): # once for a name this client minted, <session>.<number>
|
|
234
|
+
if short := _short_of_the_log(evidence.store, name):
|
|
235
|
+
found[session][int(name.rpartition(".")[2])] = short
|
|
236
|
+
holes = 0
|
|
237
|
+
for session, missing in sorted(found.items()):
|
|
238
|
+
if missing:
|
|
239
|
+
holes += len(missing)
|
|
240
|
+
shown = ", ".join(f"{n} ({missing[n]})" if missing[n] else str(n) for n in sorted(missing)[:20])
|
|
241
|
+
_say(f"{session}: {len(missing)} missing: {shown}{' …' if len(missing) > 20 else ''}")
|
|
242
|
+
if holes:
|
|
243
|
+
_say(
|
|
244
|
+
"missing: not in the log as far as this evidence shows. A bare number has no record here; "
|
|
245
|
+
"(refused) the door refused it; (pending) the door has not confirmed it, and resend sends it "
|
|
246
|
+
"again; (never sent) only its pair was kept"
|
|
247
|
+
)
|
|
248
|
+
else:
|
|
249
|
+
_say(
|
|
250
|
+
"no hole: up to the highest number of every session, each record's receipt is kept here "
|
|
251
|
+
"(which says nothing about what was never captured)"
|
|
252
|
+
)
|
|
253
|
+
return 1 if holes else 0
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _folders(folder: Path) -> None:
|
|
257
|
+
"""``folder`` and each folder above it that is missing, made the owner's alone (0700), as in the store."""
|
|
258
|
+
missing = []
|
|
259
|
+
while not folder.exists():
|
|
260
|
+
missing.append(folder)
|
|
261
|
+
folder = folder.parent
|
|
262
|
+
for made in reversed(missing):
|
|
263
|
+
made.mkdir(mode=0o700, exist_ok=True)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def export(args: argparse.Namespace) -> int:
|
|
267
|
+
store = _records(_store(args.evidence, ephemeral_ok=args.ephemeral_ok)).store
|
|
268
|
+
name = checked_record_id(args.record_id)
|
|
269
|
+
if not _is_record(store, name):
|
|
270
|
+
_say(f"{name}: no such record in {store.locate('')}")
|
|
271
|
+
return 1
|
|
272
|
+
target = Path(args.to)
|
|
273
|
+
copied, disclosed = 0, False
|
|
274
|
+
for inside in walk(store, name):
|
|
275
|
+
out = target.joinpath(*inside.split("/")[1:])
|
|
276
|
+
_folders(out.parent)
|
|
277
|
+
# The content and the salt, kept as the store keeps them: the owner's alone (0600), and never
|
|
278
|
+
# over a file already there. Sharing the copy is a person's act, done on purpose.
|
|
279
|
+
with os.fdopen(_exclusive(out), "wb") as written:
|
|
280
|
+
written.write(store.read(inside) or b"")
|
|
281
|
+
copied += 1
|
|
282
|
+
disclosed = disclosed or inside == f"{name}/{DISCLOSURE}"
|
|
283
|
+
opened = (
|
|
284
|
+
f"evidence-verify reads the disclosure at {target / DISCLOSURE}"
|
|
285
|
+
if disclosed
|
|
286
|
+
else f"it holds no {DISCLOSURE}, so nobody can open what it commits to"
|
|
287
|
+
)
|
|
288
|
+
_say(f"{name}: {copied} file(s) in {target}; {opened}")
|
|
289
|
+
return 0
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def parser() -> argparse.ArgumentParser:
|
|
293
|
+
top = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
|
294
|
+
common: dict[str, Any] = {}
|
|
295
|
+
sub = top.add_subparsers(dest="command", required=True)
|
|
296
|
+
for name, run, help_ in (
|
|
297
|
+
("doctor", doctor, "what stands between this machine and evidence somebody can check"),
|
|
298
|
+
("pending", pending, "records sent and never confirmed or refused"),
|
|
299
|
+
("resend", resend, "send pending records again, byte for byte"),
|
|
300
|
+
("gaps", show_gaps, "numbers missing from a session's sequence"),
|
|
301
|
+
("export", export, "copy one record's evidence into a folder"),
|
|
302
|
+
):
|
|
303
|
+
command = sub.add_parser(name, help=help_, **common)
|
|
304
|
+
command.set_defaults(run=run)
|
|
305
|
+
command.add_argument("--evidence", default=os.environ.get("TALLWRIGHT_EVIDENCE"))
|
|
306
|
+
command.add_argument("--ephemeral-ok", action="store_true", help="accept evidence on a file system that ends with its container")
|
|
307
|
+
if name in ("doctor", "resend"):
|
|
308
|
+
command.add_argument("--ingest", default=os.environ.get("TALLWRIGHT_INGEST"))
|
|
309
|
+
if name == "doctor":
|
|
310
|
+
command.add_argument("--timeout", type=float, default=10.0)
|
|
311
|
+
command.add_argument("--check-key", action="store_true", help="send an empty batch to check the key; records nothing")
|
|
312
|
+
if name == "pending":
|
|
313
|
+
command.add_argument("--all-keys", action="store_true", help="also records pending under another key")
|
|
314
|
+
if name == "resend":
|
|
315
|
+
command.add_argument("record_ids", nargs="*")
|
|
316
|
+
command.add_argument("--adopt", action="store_true", help="send the records named, pending under a replaced key, with this one")
|
|
317
|
+
if name == "export":
|
|
318
|
+
command.add_argument("record_id")
|
|
319
|
+
command.add_argument("to")
|
|
320
|
+
return top
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
324
|
+
args = parser().parse_args(argv)
|
|
325
|
+
try:
|
|
326
|
+
return int(args.run(args))
|
|
327
|
+
except (TallwrightError, OSError, ValueError) as failed:
|
|
328
|
+
_say(f"tallwright-cloud: {type(failed).__name__}: {failed}")
|
|
329
|
+
return 1
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
if __name__ == "__main__": # pragma: no cover
|
|
333
|
+
raise SystemExit(main())
|