temporal-flowrensic-python-sdk 0.6.63__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.
- temporal_flowrensic/__init__.py +2 -0
- temporal_flowrensic/_calls.py +185 -0
- temporal_flowrensic/_ci.py +56 -0
- temporal_flowrensic/_deploy.py +65 -0
- temporal_flowrensic/_gen/flowrensic/v1/activity_metrics_pb2.py +76 -0
- temporal_flowrensic/_gen/flowrensic/v1/activity_metrics_pb2.pyi +301 -0
- temporal_flowrensic/_gen/flowrensic/v1/catalog_pb2.py +60 -0
- temporal_flowrensic/_gen/flowrensic/v1/catalog_pb2.pyi +127 -0
- temporal_flowrensic/_gen/flowrensic/v1/catch_up_pb2.py +42 -0
- temporal_flowrensic/_gen/flowrensic/v1/catch_up_pb2.pyi +71 -0
- temporal_flowrensic/_gen/flowrensic/v1/client_calls_pb2.py +49 -0
- temporal_flowrensic/_gen/flowrensic/v1/client_calls_pb2.pyi +90 -0
- temporal_flowrensic/_gen/flowrensic/v1/cost_pb2.py +52 -0
- temporal_flowrensic/_gen/flowrensic/v1/cost_pb2.pyi +107 -0
- temporal_flowrensic/_gen/flowrensic/v1/deploy_pb2.py +51 -0
- temporal_flowrensic/_gen/flowrensic/v1/deploy_pb2.pyi +92 -0
- temporal_flowrensic/_gen/flowrensic/v1/ephemeral_run_pb2.py +107 -0
- temporal_flowrensic/_gen/flowrensic/v1/ephemeral_run_pb2.pyi +344 -0
- temporal_flowrensic/_gen/flowrensic/v1/finding_pb2.py +76 -0
- temporal_flowrensic/_gen/flowrensic/v1/finding_pb2.pyi +371 -0
- temporal_flowrensic/_gen/flowrensic/v1/job_pb2.py +46 -0
- temporal_flowrensic/_gen/flowrensic/v1/job_pb2.pyi +51 -0
- temporal_flowrensic/_gen/flowrensic/v1/load_pb2.py +51 -0
- temporal_flowrensic/_gen/flowrensic/v1/load_pb2.pyi +118 -0
- temporal_flowrensic/_gen/flowrensic/v1/page_pb2.py +38 -0
- temporal_flowrensic/_gen/flowrensic/v1/page_pb2.pyi +23 -0
- temporal_flowrensic/_gen/flowrensic/v1/remediation_pb2.py +60 -0
- temporal_flowrensic/_gen/flowrensic/v1/remediation_pb2.pyi +157 -0
- temporal_flowrensic/_gen/flowrensic/v1/replay_pb2.py +84 -0
- temporal_flowrensic/_gen/flowrensic/v1/replay_pb2.pyi +309 -0
- temporal_flowrensic/_gen/flowrensic/v1/server_config_pb2.py +48 -0
- temporal_flowrensic/_gen/flowrensic/v1/server_config_pb2.pyi +97 -0
- temporal_flowrensic/_gen/flowrensic/v1/services_connect.py +3621 -0
- temporal_flowrensic/_gen/flowrensic/v1/services_pb2.py +60 -0
- temporal_flowrensic/_gen/flowrensic/v1/services_pb2.pyi +20 -0
- temporal_flowrensic/_gen/flowrensic/v1/stored_data_pb2.py +43 -0
- temporal_flowrensic/_gen/flowrensic/v1/stored_data_pb2.pyi +50 -0
- temporal_flowrensic/_gen/flowrensic/v1/workflow_history_pb2.py +47 -0
- temporal_flowrensic/_gen/flowrensic/v1/workflow_history_pb2.pyi +102 -0
- temporal_flowrensic/_gen/flowrensic/v1/workflow_types_pb2.py +55 -0
- temporal_flowrensic/_gen/flowrensic/v1/workflow_types_pb2.pyi +197 -0
- temporal_flowrensic/_names.py +42 -0
- temporal_flowrensic/_queries.py +61 -0
- temporal_flowrensic/_replay.py +378 -0
- temporal_flowrensic/_sink.py +574 -0
- temporal_flowrensic/_telemetry.py +559 -0
- temporal_flowrensic/activity.py +136 -0
- temporal_flowrensic/client.py +166 -0
- temporal_flowrensic/py.typed +0 -0
- temporal_flowrensic/workflow.py +103 -0
- temporal_flowrensic_python_sdk-0.6.63.dist-info/METADATA +74 -0
- temporal_flowrensic_python_sdk-0.6.63.dist-info/RECORD +54 -0
- temporal_flowrensic_python_sdk-0.6.63.dist-info/WHEEL +4 -0
- temporal_flowrensic_python_sdk-0.6.63.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""The names of workflow interceptors, as Flowrensic is sent them by a worker
|
|
2
|
+
and by a replay, so the two lists can be compared."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any, Optional, Sequence
|
|
8
|
+
|
|
9
|
+
from temporal_flowrensic._gen.flowrensic.v1.replay_pb2 import WorkflowInterceptors
|
|
10
|
+
|
|
11
|
+
__all__ = ["interceptor_list", "interceptor_name"]
|
|
12
|
+
|
|
13
|
+
_EXTENSIONS = re.compile(r"\.(?:[cm]?[jt]s|[jt]sx|py)$")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def interceptor_name(interceptor: Any) -> str:
|
|
17
|
+
"""The name of a workflow interceptor. A path or module name is named by
|
|
18
|
+
its file name without the folder and the extension, and a file named
|
|
19
|
+
index or __init__ by the name of its folder, as the Node SDK names a
|
|
20
|
+
module. So /app/interceptors/metrics.py, src/interceptors/metrics.py and
|
|
21
|
+
app.interceptors.metrics are all metrics. A class is named by its class
|
|
22
|
+
name, and any other object by the name of its class."""
|
|
23
|
+
if isinstance(interceptor, str):
|
|
24
|
+
parts = [part for part in re.split(r"[/\\]", interceptor) if part]
|
|
25
|
+
file = _EXTENSIONS.sub("", parts[-1] if parts else "")
|
|
26
|
+
if "/" not in interceptor and "\\" not in interceptor and "." in file:
|
|
27
|
+
dotted = [part for part in file.split(".") if part]
|
|
28
|
+
parts = dotted
|
|
29
|
+
file = dotted[-1] if dotted else ""
|
|
30
|
+
if file in ("index", "__init__") and len(parts) > 1:
|
|
31
|
+
return _EXTENSIONS.sub("", parts[-2])
|
|
32
|
+
return file
|
|
33
|
+
if isinstance(interceptor, type):
|
|
34
|
+
return interceptor.__name__
|
|
35
|
+
return type(interceptor).__name__
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def interceptor_list(interceptors: Optional[Sequence[Any]]) -> Optional[WorkflowInterceptors]:
|
|
39
|
+
"""The interceptors as Flowrensic is sent them, in their order. None sends no list."""
|
|
40
|
+
if interceptors is None:
|
|
41
|
+
return None
|
|
42
|
+
return WorkflowInterceptors(names=[interceptor_name(one) for one in interceptors])
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""The buffer of the queries the workflows of a worker answered, counted per
|
|
2
|
+
workflow type, query name and minute when a batch is sent."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from temporal_flowrensic._gen.flowrensic.v1.client_calls_pb2 import IngestQueryCountsRequest, QueryCount
|
|
9
|
+
from temporal_flowrensic._sink import BufferTarget, SendId, client_headers, ingest_client_sync
|
|
10
|
+
|
|
11
|
+
__all__ = ["Answered", "QueryTarget"]
|
|
12
|
+
|
|
13
|
+
_MINUTE = 60
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Answered:
|
|
18
|
+
"""One query a workflow answered, ok when its handler returned, at a time in seconds."""
|
|
19
|
+
|
|
20
|
+
namespace: str
|
|
21
|
+
workflow_type: str
|
|
22
|
+
name: str
|
|
23
|
+
ok: bool
|
|
24
|
+
at: float
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class QueryTarget(BufferTarget[Answered]):
|
|
28
|
+
def __init__(
|
|
29
|
+
self, server_base_url: str, env: str, env_access_key: str, ephemeral_run: str
|
|
30
|
+
) -> None:
|
|
31
|
+
self._ingest = ingest_client_sync(server_base_url)
|
|
32
|
+
self._env = env
|
|
33
|
+
self._headers = client_headers(env, env_access_key, ephemeral_run)
|
|
34
|
+
|
|
35
|
+
def send(self, records: list[Answered], timeout_ms: int, id: SendId) -> None:
|
|
36
|
+
counts: dict[tuple[str, str, str, int], QueryCount] = {}
|
|
37
|
+
for one in records:
|
|
38
|
+
minute = int(one.at // _MINUTE) * _MINUTE
|
|
39
|
+
key = (one.namespace, one.workflow_type, one.name, minute)
|
|
40
|
+
count = counts.get(key)
|
|
41
|
+
if count is None:
|
|
42
|
+
count = QueryCount(
|
|
43
|
+
namespace=one.namespace,
|
|
44
|
+
env=self._env,
|
|
45
|
+
workflow_type=one.workflow_type,
|
|
46
|
+
query_name=one.name,
|
|
47
|
+
)
|
|
48
|
+
count.minute.FromSeconds(minute)
|
|
49
|
+
counts[key] = count
|
|
50
|
+
if one.ok:
|
|
51
|
+
count.answered += 1
|
|
52
|
+
else:
|
|
53
|
+
count.failed += 1
|
|
54
|
+
self._ingest.ingest_query_counts(
|
|
55
|
+
IngestQueryCountsRequest(counts=list(counts.values()), sender=id.sender, send_number=id.send_number),
|
|
56
|
+
headers=self._headers,
|
|
57
|
+
timeout_ms=max(1, timeout_ms),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def env_of(self, record: Answered) -> str:
|
|
61
|
+
return self._env
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""Replay before deploy: fetch the replay set Flowrensic picks for a workflow
|
|
2
|
+
type in each of the envs named, write it to disk the way the CLI and the Node SDK do, and send
|
|
3
|
+
the verdicts of a replay back. Nothing here runs a workflow; the CI job
|
|
4
|
+
replays with temporalio's Replayer."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from typing import Any, Optional, Sequence
|
|
16
|
+
|
|
17
|
+
from connectrpc.code import Code
|
|
18
|
+
from connectrpc.errors import ConnectError
|
|
19
|
+
from google.protobuf.timestamp_pb2 import Timestamp
|
|
20
|
+
|
|
21
|
+
from temporal_flowrensic._ci import ci_error, ci_failure
|
|
22
|
+
from temporal_flowrensic._gen.flowrensic.v1.replay_pb2 import (
|
|
23
|
+
RUN_STATUS_CANCELED,
|
|
24
|
+
RUN_STATUS_COMPLETED,
|
|
25
|
+
RUN_STATUS_CONTINUED_AS_NEW,
|
|
26
|
+
RUN_STATUS_FAILED,
|
|
27
|
+
RUN_STATUS_PAUSED,
|
|
28
|
+
RUN_STATUS_RUNNING,
|
|
29
|
+
RUN_STATUS_TERMINATED,
|
|
30
|
+
RUN_STATUS_TIMED_OUT,
|
|
31
|
+
RUN_STATUS_UNSPECIFIED,
|
|
32
|
+
IngestReplayResultsRequest,
|
|
33
|
+
ListReplayHistoriesRequest,
|
|
34
|
+
ListReplayHistoriesResponse,
|
|
35
|
+
ReplayResult,
|
|
36
|
+
)
|
|
37
|
+
from temporal_flowrensic._gen.flowrensic.v1.page_pb2 import PageRequest
|
|
38
|
+
from temporal_flowrensic._gen.flowrensic.v1.services_connect import IngestServiceClient, QueryServiceClient
|
|
39
|
+
from temporal_flowrensic._names import interceptor_list
|
|
40
|
+
from temporal_flowrensic._sink import client_headers
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"INDEX_NAME",
|
|
44
|
+
"LeftOutEntry",
|
|
45
|
+
"ReplaySet",
|
|
46
|
+
"ReplaySetEntry",
|
|
47
|
+
"ReplayVerdict",
|
|
48
|
+
"fetch_replay_set",
|
|
49
|
+
"list_history_files",
|
|
50
|
+
"publish_verdicts",
|
|
51
|
+
"read_replay_set",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
INDEX_NAME = "replay-set.json"
|
|
55
|
+
"""The manifest written beside the histories, the same one the CLI writes."""
|
|
56
|
+
|
|
57
|
+
_DOWNLOAD_TIMEOUT_MS = 60_000
|
|
58
|
+
_PUBLISH_TIMEOUT_MS = 30_000
|
|
59
|
+
_SEND_AGAIN_FOR = 600.0
|
|
60
|
+
|
|
61
|
+
# A manifest reads completed, the same label the CLI writes.
|
|
62
|
+
_STATUS_LABELS = {
|
|
63
|
+
RUN_STATUS_UNSPECIFIED: "unspecified",
|
|
64
|
+
RUN_STATUS_RUNNING: "running",
|
|
65
|
+
RUN_STATUS_COMPLETED: "completed",
|
|
66
|
+
RUN_STATUS_FAILED: "failed",
|
|
67
|
+
RUN_STATUS_CANCELED: "canceled",
|
|
68
|
+
RUN_STATUS_TERMINATED: "terminated",
|
|
69
|
+
RUN_STATUS_TIMED_OUT: "timed_out",
|
|
70
|
+
RUN_STATUS_CONTINUED_AS_NEW: "continued_as_new",
|
|
71
|
+
RUN_STATUS_PAUSED: "paused",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _iso(ts: Optional[Timestamp]) -> str:
|
|
76
|
+
seconds = 0.0 if ts is None else ts.seconds + ts.nanos / 1e9
|
|
77
|
+
return datetime.fromtimestamp(seconds, tz=timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _now() -> str:
|
|
81
|
+
return datetime.now(tz=timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class ReplaySetEntry:
|
|
86
|
+
"""One history on disk, with what the server chose it by."""
|
|
87
|
+
|
|
88
|
+
run_id: str
|
|
89
|
+
file: str
|
|
90
|
+
status: str
|
|
91
|
+
code_versions: list[str]
|
|
92
|
+
events: int
|
|
93
|
+
started_at: str
|
|
94
|
+
env: Optional[str] = None
|
|
95
|
+
closed_at: Optional[str] = None
|
|
96
|
+
|
|
97
|
+
def to_json(self) -> dict[str, Any]:
|
|
98
|
+
out: dict[str, Any] = {"run_id": self.run_id, "file": self.file, "status": self.status}
|
|
99
|
+
if self.env is not None:
|
|
100
|
+
out["env"] = self.env
|
|
101
|
+
out["code_versions"] = list(self.code_versions)
|
|
102
|
+
out["events"] = self.events
|
|
103
|
+
out["started_at"] = self.started_at
|
|
104
|
+
if self.closed_at is not None:
|
|
105
|
+
out["closed_at"] = self.closed_at
|
|
106
|
+
return out
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class LeftOutEntry:
|
|
111
|
+
"""A stored run the server could not read, so it is not in the set."""
|
|
112
|
+
|
|
113
|
+
run_id: str
|
|
114
|
+
reason: str
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True)
|
|
118
|
+
class ReplaySet:
|
|
119
|
+
"""The set of one workflow type in the envs named, as the manifest holds it."""
|
|
120
|
+
|
|
121
|
+
workflow_type: str
|
|
122
|
+
envs: list[str]
|
|
123
|
+
"""The envs the histories were fetched from, in the order they were named."""
|
|
124
|
+
fetched_at: str
|
|
125
|
+
server: str
|
|
126
|
+
histories: list[ReplaySetEntry]
|
|
127
|
+
left_out: list[LeftOutEntry] = field(default_factory=list)
|
|
128
|
+
failed: Optional[str] = None
|
|
129
|
+
"""Why the download failed, when fail_on_error was False and it did. The set is then empty."""
|
|
130
|
+
|
|
131
|
+
def to_json(self) -> dict[str, Any]:
|
|
132
|
+
"""The manifest as the CLI and the Node SDK write it."""
|
|
133
|
+
out: dict[str, Any] = {"workflow_type": self.workflow_type, "envs": list(self.envs)}
|
|
134
|
+
out["fetched_at"] = self.fetched_at
|
|
135
|
+
out["server"] = self.server
|
|
136
|
+
out["histories"] = [one.to_json() for one in self.histories]
|
|
137
|
+
out["left_out"] = [{"run_id": one.run_id, "reason": one.reason} for one in self.left_out]
|
|
138
|
+
if self.failed is not None:
|
|
139
|
+
out["failed"] = self.failed
|
|
140
|
+
return out
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def from_json(value: Any) -> ReplaySet:
|
|
144
|
+
if not isinstance(value, dict) or not isinstance(value.get("workflow_type"), str):
|
|
145
|
+
raise ValueError("not a replay set manifest")
|
|
146
|
+
if not isinstance(value.get("histories"), list):
|
|
147
|
+
raise ValueError("not a replay set manifest")
|
|
148
|
+
return ReplaySet(
|
|
149
|
+
workflow_type=value["workflow_type"],
|
|
150
|
+
envs=[str(one) for one in value.get("envs", [])],
|
|
151
|
+
fetched_at=value.get("fetched_at", ""),
|
|
152
|
+
server=value.get("server", ""),
|
|
153
|
+
histories=[
|
|
154
|
+
ReplaySetEntry(
|
|
155
|
+
run_id=one["run_id"],
|
|
156
|
+
file=one["file"],
|
|
157
|
+
status=one.get("status", ""),
|
|
158
|
+
env=one.get("env"),
|
|
159
|
+
code_versions=list(one.get("code_versions", [])),
|
|
160
|
+
events=one.get("events", 0),
|
|
161
|
+
started_at=one.get("started_at", ""),
|
|
162
|
+
closed_at=one.get("closed_at"),
|
|
163
|
+
)
|
|
164
|
+
for one in value["histories"]
|
|
165
|
+
],
|
|
166
|
+
left_out=[LeftOutEntry(one["run_id"], one.get("reason", "")) for one in value.get("left_out", [])],
|
|
167
|
+
failed=value.get("failed"),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass(frozen=True)
|
|
172
|
+
class ReplayVerdict:
|
|
173
|
+
"""What one history did when replayed against the code under test."""
|
|
174
|
+
|
|
175
|
+
run_id: str
|
|
176
|
+
workflow_type: str
|
|
177
|
+
passed: bool
|
|
178
|
+
error: Optional[str] = None
|
|
179
|
+
"""The replayer's own message, kept as it is: it names what diverged."""
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _write(path: str, data: bytes) -> None:
|
|
183
|
+
with open(path, "wb") as f:
|
|
184
|
+
f.write(data)
|
|
185
|
+
os.chmod(path, 0o600)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
async def fetch_replay_set(
|
|
189
|
+
*,
|
|
190
|
+
server_base_url: str,
|
|
191
|
+
replay_history_workflow: str,
|
|
192
|
+
replay_history_out_dir: str,
|
|
193
|
+
replay_history_from_envs: Sequence[str],
|
|
194
|
+
current_env: str,
|
|
195
|
+
env_access_key: str,
|
|
196
|
+
current_ephemeral_run: str = "",
|
|
197
|
+
timeout_ms: int = _DOWNLOAD_TIMEOUT_MS,
|
|
198
|
+
fail_on_error: bool = True,
|
|
199
|
+
) -> ReplaySet:
|
|
200
|
+
"""Fetches the set the server picks for a workflow type in each env named,
|
|
201
|
+
in one request, and writes each history to replay_history_out_dir as
|
|
202
|
+
RUN_ID.json, as the server serves it, and the manifest replay-set.json
|
|
203
|
+
beside them.
|
|
204
|
+
|
|
205
|
+
replay_history_from_envs is a list of env names, such as ["prod",
|
|
206
|
+
"staging"]. Each must be current_env or an env it may read. A name given
|
|
207
|
+
twice is read once. A plain string raises ValueError. current_env,
|
|
208
|
+
env_access_key and current_ephemeral_run are those of the caller and go in
|
|
209
|
+
their headers.
|
|
210
|
+
|
|
211
|
+
The download has a time limit of timeout_ms milliseconds, 60000 by
|
|
212
|
+
default. When it passes or
|
|
213
|
+
Flowrensic refuses, it raises CIError with a message that says Flowrensic
|
|
214
|
+
could not be reached or what it answered. With fail_on_error False it
|
|
215
|
+
returns an empty set whose failed field says why, and writes nothing."""
|
|
216
|
+
if isinstance(replay_history_from_envs, str):
|
|
217
|
+
raise ValueError("flowrensic: replay_history_from_envs takes a list of env names, got a plain string")
|
|
218
|
+
workflow_type = replay_history_workflow
|
|
219
|
+
envs = list(dict.fromkeys(replay_history_from_envs))
|
|
220
|
+
dir = replay_history_out_dir
|
|
221
|
+
try:
|
|
222
|
+
async with QueryServiceClient(server_base_url.rstrip("/"), send_compression=None) as query:
|
|
223
|
+
# The answer comes in pages of whole env sets: ask until the server
|
|
224
|
+
# says there is no more, and keep every page in order.
|
|
225
|
+
found = ListReplayHistoriesResponse()
|
|
226
|
+
page_token = ""
|
|
227
|
+
while True:
|
|
228
|
+
page = await query.list_replay_histories(
|
|
229
|
+
ListReplayHistoriesRequest(
|
|
230
|
+
workflow_type=workflow_type, envs=envs, page=PageRequest(page_token=page_token)
|
|
231
|
+
),
|
|
232
|
+
headers=client_headers(current_env, env_access_key, current_ephemeral_run),
|
|
233
|
+
timeout_ms=max(1, timeout_ms),
|
|
234
|
+
)
|
|
235
|
+
found.histories.extend(page.histories)
|
|
236
|
+
found.left_out.extend(page.left_out)
|
|
237
|
+
page_token = page.page.next_page_token
|
|
238
|
+
if not page_token:
|
|
239
|
+
break
|
|
240
|
+
except Exception as error: # noqa: BLE001
|
|
241
|
+
if fail_on_error:
|
|
242
|
+
raise ci_error("replay set download", server_base_url, timeout_ms, error) from error
|
|
243
|
+
return ReplaySet(
|
|
244
|
+
workflow_type=workflow_type,
|
|
245
|
+
envs=envs,
|
|
246
|
+
fetched_at=_now(),
|
|
247
|
+
server=server_base_url,
|
|
248
|
+
histories=[],
|
|
249
|
+
failed=ci_failure("replay set download", server_base_url, timeout_ms, error),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
os.makedirs(dir, mode=0o750, exist_ok=True)
|
|
253
|
+
histories = []
|
|
254
|
+
for history in found.histories:
|
|
255
|
+
file = f"{history.run_id}.json"
|
|
256
|
+
# Written and renamed, so a failed write never leaves half a history.
|
|
257
|
+
temp = os.path.join(dir, f".fetch-{history.run_id}")
|
|
258
|
+
_write(temp, history.history)
|
|
259
|
+
os.replace(temp, os.path.join(dir, file))
|
|
260
|
+
histories.append(
|
|
261
|
+
ReplaySetEntry(
|
|
262
|
+
run_id=history.run_id,
|
|
263
|
+
file=file,
|
|
264
|
+
status=_STATUS_LABELS.get(history.status, "unspecified"),
|
|
265
|
+
env=history.env or None,
|
|
266
|
+
code_versions=list(history.code_versions),
|
|
267
|
+
events=history.events,
|
|
268
|
+
started_at=_iso(history.started_at if history.HasField("started_at") else None),
|
|
269
|
+
closed_at=_iso(history.closed_at) if history.HasField("closed_at") else None,
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
found_set = ReplaySet(
|
|
273
|
+
workflow_type=workflow_type,
|
|
274
|
+
envs=envs,
|
|
275
|
+
fetched_at=_now(),
|
|
276
|
+
server=server_base_url,
|
|
277
|
+
histories=histories,
|
|
278
|
+
left_out=[LeftOutEntry(one.run_id, one.reason) for one in found.left_out],
|
|
279
|
+
)
|
|
280
|
+
_write(os.path.join(dir, INDEX_NAME), (json.dumps(found_set.to_json(), indent=2) + "\n").encode("utf-8"))
|
|
281
|
+
_log_fetched(found_set, len(found.histories), dir)
|
|
282
|
+
return found_set
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _log_fetched(found_set: ReplaySet, got: int, dir: str) -> None:
|
|
286
|
+
"""Writes to stderr what a download got, the same lines in every SDK and
|
|
287
|
+
the CLI: the workflow type and the envs, how many histories the answer held
|
|
288
|
+
and how many were saved, the run id of each, and each run left out with its
|
|
289
|
+
reason."""
|
|
290
|
+
lines = [
|
|
291
|
+
f"replay set: workflow {found_set.workflow_type}, envs {', '.join(found_set.envs)}",
|
|
292
|
+
f"got {got} histories, saved {len(found_set.histories)} in {dir}",
|
|
293
|
+
*(f" {one.run_id}" for one in found_set.histories),
|
|
294
|
+
*(f"left out {one.run_id}: {one.reason}" for one in found_set.left_out),
|
|
295
|
+
]
|
|
296
|
+
sys.stderr.write("\n".join(lines) + "\n")
|
|
297
|
+
sys.stderr.flush()
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def read_replay_set(dir: str) -> ReplaySet:
|
|
301
|
+
"""Reads a manifest a fetch left behind, the CLI's or this package's."""
|
|
302
|
+
path = os.path.join(dir, INDEX_NAME)
|
|
303
|
+
with open(path, encoding="utf-8") as f:
|
|
304
|
+
try:
|
|
305
|
+
return ReplaySet.from_json(json.load(f))
|
|
306
|
+
except ValueError as error:
|
|
307
|
+
raise ValueError(f"{path} is not a replay set manifest") from error
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def list_history_files(dir: str) -> list[str]:
|
|
311
|
+
"""The histories in a directory, without the manifest."""
|
|
312
|
+
return sorted(name for name in os.listdir(dir) if name.endswith(".json") and name != INDEX_NAME)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _wait_before_sending_again(error: BaseException) -> Optional[float]:
|
|
316
|
+
"""Seconds to wait before sending again a call that Flowrensic did not
|
|
317
|
+
answer or answered try again later, one second. None for any other error,
|
|
318
|
+
which is not sent again."""
|
|
319
|
+
if not isinstance(error, ConnectError):
|
|
320
|
+
return None
|
|
321
|
+
if error.code not in (Code.UNAVAILABLE, Code.DEADLINE_EXCEEDED, Code.RESOURCE_EXHAUSTED):
|
|
322
|
+
return None
|
|
323
|
+
return 1.0
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
async def publish_verdicts(
|
|
327
|
+
*,
|
|
328
|
+
server_base_url: str,
|
|
329
|
+
code_version: str,
|
|
330
|
+
verdicts: Sequence[ReplayVerdict],
|
|
331
|
+
current_env: str,
|
|
332
|
+
env_access_key: str,
|
|
333
|
+
current_ephemeral_run: str = "",
|
|
334
|
+
workflow_interceptors: Optional[Sequence[Any]] = None,
|
|
335
|
+
send_again_for: float = _SEND_AGAIN_FOR,
|
|
336
|
+
timeout_ms: int = _PUBLISH_TIMEOUT_MS,
|
|
337
|
+
) -> None:
|
|
338
|
+
"""Reports what a replay found, against the history and the code version
|
|
339
|
+
replayed, so a break is put down to a code change. workflow_interceptors
|
|
340
|
+
are the workflow interceptors the replay ran with, sent by name; left out,
|
|
341
|
+
no list is sent. A call Flowrensic does not answer, or answers try again
|
|
342
|
+
later, is sent again for up to send_again_for seconds, 10 minutes by
|
|
343
|
+
default. Each call has a time limit of timeout_ms milliseconds, 30000 by
|
|
344
|
+
default. When it gives up or Flowrensic refuses, it raises CIError.
|
|
345
|
+
current_env, env_access_key and current_ephemeral_run go in their headers."""
|
|
346
|
+
if not verdicts:
|
|
347
|
+
return
|
|
348
|
+
interceptors = interceptor_list(workflow_interceptors)
|
|
349
|
+
replayed_at = Timestamp()
|
|
350
|
+
replayed_at.FromNanoseconds(int(time.time() * 1_000_000_000))
|
|
351
|
+
results = []
|
|
352
|
+
for v in verdicts:
|
|
353
|
+
one = ReplayResult(
|
|
354
|
+
run_id=v.run_id,
|
|
355
|
+
code_version=code_version,
|
|
356
|
+
workflow_type=v.workflow_type,
|
|
357
|
+
passed=v.passed,
|
|
358
|
+
error=v.error or "",
|
|
359
|
+
replayed_at=replayed_at,
|
|
360
|
+
)
|
|
361
|
+
if interceptors is not None:
|
|
362
|
+
one.workflow_interceptors.CopyFrom(interceptors)
|
|
363
|
+
results.append(one)
|
|
364
|
+
give_up_at = time.monotonic() + send_again_for
|
|
365
|
+
async with IngestServiceClient(server_base_url.rstrip("/"), send_compression=None) as ingest:
|
|
366
|
+
while True:
|
|
367
|
+
try:
|
|
368
|
+
await ingest.ingest_replay_results(
|
|
369
|
+
IngestReplayResultsRequest(results=results),
|
|
370
|
+
headers=client_headers(current_env, env_access_key, current_ephemeral_run),
|
|
371
|
+
timeout_ms=max(1, timeout_ms),
|
|
372
|
+
)
|
|
373
|
+
return
|
|
374
|
+
except Exception as error: # noqa: BLE001
|
|
375
|
+
wait = _wait_before_sending_again(error)
|
|
376
|
+
if wait is None or time.monotonic() + wait > give_up_at:
|
|
377
|
+
raise ci_error("replay results", server_base_url, timeout_ms, error) from error
|
|
378
|
+
await asyncio.sleep(wait)
|