bitfab 0.62.3 → 0.62.5
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.
- package/cloudReplay.py +1065 -0
- package/dist/{chunk-E2QXILJI.js → chunk-COFCJXSZ.js} +6 -3
- package/dist/{chunk-E2QXILJI.js.map → chunk-COFCJXSZ.js.map} +1 -1
- package/dist/{chunk-BPOAXWVS.js → chunk-SDEDJXKM.js} +78 -6
- package/dist/chunk-SDEDJXKM.js.map +1 -0
- package/dist/{chunk-YKLSCOBE.js → chunk-SPGTIHTC.js} +21 -3
- package/dist/chunk-SPGTIHTC.js.map +1 -0
- package/dist/{chunk-DRRJRCVG.js → chunk-UUB5LHWU.js} +6 -3
- package/dist/{chunk-DRRJRCVG.js.map → chunk-UUB5LHWU.js.map} +1 -1
- package/dist/{chunk-T6O3MFM4.js → chunk-XADZNIOO.js} +4 -3
- package/dist/chunk-XADZNIOO.js.map +1 -0
- package/dist/{http-SWK5U3S4.js → http-LM5MZIOW.js} +2 -2
- package/dist/{http-GHK5JKGG.js → http-TVY52JJZ.js} +2 -2
- package/dist/index.cjs +80 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +113 -3
- package/dist/index.d.ts +113 -3
- package/dist/index.js +5 -3
- package/dist/node.cjs +80 -3
- package/dist/node.cjs.map +1 -1
- package/dist/node.d.cts +1 -1
- package/dist/node.d.ts +1 -1
- package/dist/node.js +5 -3
- package/dist/node.js.map +1 -1
- package/dist/{replay-BN4FM6MX.js → replay-SWPDIBQG.js} +3 -3
- package/dist/replayCli.js +8 -23
- package/dist/replayCli.js.map +1 -1
- package/dist/seedCli.js +2 -2
- package/package.json +3 -2
- package/dist/chunk-BPOAXWVS.js.map +0 -1
- package/dist/chunk-T6O3MFM4.js.map +0 -1
- package/dist/chunk-YKLSCOBE.js.map +0 -1
- /package/dist/{http-GHK5JKGG.js.map → http-LM5MZIOW.js.map} +0 -0
- /package/dist/{http-SWK5U3S4.js.map → http-TVY52JJZ.js.map} +0 -0
- /package/dist/{replay-BN4FM6MX.js.map → replay-SWPDIBQG.js.map} +0 -0
package/cloudReplay.py
ADDED
|
@@ -0,0 +1,1065 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import base64
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shlex
|
|
10
|
+
import signal
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from urllib.parse import urlencode
|
|
18
|
+
|
|
19
|
+
CONFIG = ".bitfab/cloud.json"
|
|
20
|
+
PREFIX = "bitfab-replay/"
|
|
21
|
+
DEFAULT_SECRET_PREFIX = "BITFAB_CLOUD_"
|
|
22
|
+
API_VERSION = "2026-03-10"
|
|
23
|
+
ITEM_ERROR_FIELDS = (
|
|
24
|
+
"error",
|
|
25
|
+
"traceError",
|
|
26
|
+
"trace_error",
|
|
27
|
+
"replayError",
|
|
28
|
+
"replay_error",
|
|
29
|
+
)
|
|
30
|
+
UUID = re.compile(r"^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$")
|
|
31
|
+
SHA = re.compile(r"^[0-9a-f]{40}$")
|
|
32
|
+
PIPELINE = re.compile(r"^[\w.][\w.-]*$")
|
|
33
|
+
HELP = """GitHub cloud replay (requires git, gh login, and Python 3.10+).
|
|
34
|
+
--cloud PIPELINE --trace-ids UUID[,UUID] [--registry PATH]
|
|
35
|
+
[--max-concurrency 1..32] [--cloud-request-id UUID]
|
|
36
|
+
[--cloud-include PATH ...] [--cloud-dry-run] [--cloud-detach]
|
|
37
|
+
--cloud-status UUID | --cloud-watch UUID | --cloud-cancel UUID
|
|
38
|
+
--cloud-cleanup UUID
|
|
39
|
+
--cloud-init --config SPEC | --cloud-secrets --env-file FILE [NAME ...]
|
|
40
|
+
PIPELINE may be any pipeline in the registry, unless .bitfab/cloud.json names one.
|
|
41
|
+
Snapshot tracked working files without changing HEAD, the index, or local files.
|
|
42
|
+
New files require explicit --cloud-include. Credentials and ignored files are refused.
|
|
43
|
+
By default wait for completion and remove the remote snapshot branch. Detached runs
|
|
44
|
+
continue on GitHub; watch/status/cleanup can recover them using the printed UUID.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def command(args, *, cwd=None, env=None, timeout=60, input=None):
|
|
49
|
+
result = subprocess.run(
|
|
50
|
+
args,
|
|
51
|
+
cwd=cwd,
|
|
52
|
+
env=env,
|
|
53
|
+
input=input,
|
|
54
|
+
text=True,
|
|
55
|
+
check=False,
|
|
56
|
+
capture_output=True,
|
|
57
|
+
timeout=timeout,
|
|
58
|
+
)
|
|
59
|
+
if result.returncode:
|
|
60
|
+
# Child errors can contain credential-bearing URLs or application output.
|
|
61
|
+
raise RuntimeError(
|
|
62
|
+
f"{args[0]} {args[1]} failed (exit {result.returncode}); check authentication and permissions"
|
|
63
|
+
)
|
|
64
|
+
return result.stdout.strip()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def git(root, *args, env=None, input=None):
|
|
68
|
+
return command(["git", "-C", str(root), *args], env=env, input=input)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def root_directory():
|
|
72
|
+
return Path(command(["git", "rev-parse", "--show-toplevel"])).resolve()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def relative_path(value):
|
|
76
|
+
if not isinstance(value, str) or not value or "\\" in value or "\x00" in value:
|
|
77
|
+
raise ValueError("Expected a repository-relative path")
|
|
78
|
+
if Path(value).is_absolute() or ".." in Path(value).parts or value.startswith("-"):
|
|
79
|
+
raise ValueError("Paths must stay inside the repository")
|
|
80
|
+
return value
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def within(root, value):
|
|
84
|
+
path = (root / relative_path(value)).resolve()
|
|
85
|
+
path.relative_to(root)
|
|
86
|
+
return path
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def repository(root):
|
|
90
|
+
remote = git(root, "remote", "get-url", "origin")
|
|
91
|
+
match = re.fullmatch(
|
|
92
|
+
r"(?:git@github\.com:|https://github\.com/)([\w.-]+/[\w.-]+?)(?:\.git)?", remote
|
|
93
|
+
)
|
|
94
|
+
if match is None:
|
|
95
|
+
raise ValueError(
|
|
96
|
+
"origin must be a github.com repository without embedded credentials"
|
|
97
|
+
)
|
|
98
|
+
# A pushurl can silently send the snapshot somewhere other than origin's fetch URL.
|
|
99
|
+
push = git(root, "remote", "get-url", "--push", "origin")
|
|
100
|
+
other = re.fullmatch(
|
|
101
|
+
r"(?:git@github\.com:|https://github\.com/)([\w.-]+/[\w.-]+?)(?:\.git)?", push
|
|
102
|
+
)
|
|
103
|
+
if other is None or other[1].lower() != match[1].lower():
|
|
104
|
+
raise ValueError("origin fetch and push repositories must match")
|
|
105
|
+
return match[1]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def configuration(root):
|
|
109
|
+
path = within(root, CONFIG)
|
|
110
|
+
if not path.is_file():
|
|
111
|
+
raise ValueError(
|
|
112
|
+
f"Run bitfab:setup cloud first. Expected {CONFIG} inside this repository."
|
|
113
|
+
)
|
|
114
|
+
config = json.loads(path.read_text())
|
|
115
|
+
validate_config(config)
|
|
116
|
+
return config
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
ENV_ASSIGNMENT = re.compile(r"[ \t]*(?:export[ \t]+)?([A-Za-z_][A-Za-z0-9_]*)[ \t]*=")
|
|
120
|
+
|
|
121
|
+
ENV_ESCAPES = {"n": "\n", "r": "\r", "t": "\t", '"': '"', "\\": "\\"}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def read_environment_value(text, assignment_end, name):
|
|
125
|
+
start = assignment_end
|
|
126
|
+
while start < len(text) and text[start] in " \t":
|
|
127
|
+
start += 1
|
|
128
|
+
if start < len(text) and text[start] in ("'", '"'):
|
|
129
|
+
quote = text[start]
|
|
130
|
+
pieces = []
|
|
131
|
+
position = start + 1
|
|
132
|
+
while position < len(text):
|
|
133
|
+
character = text[position]
|
|
134
|
+
if character == "\\" and quote == '"' and position + 1 < len(text):
|
|
135
|
+
following = text[position + 1]
|
|
136
|
+
pieces.append(ENV_ESCAPES.get(following, character + following))
|
|
137
|
+
position += 2
|
|
138
|
+
continue
|
|
139
|
+
if character == quote:
|
|
140
|
+
return "".join(pieces), position + 1
|
|
141
|
+
pieces.append(character)
|
|
142
|
+
position += 1
|
|
143
|
+
raise ValueError(f"{name} opens a quote that the file never closes")
|
|
144
|
+
end = text.find("\n", assignment_end)
|
|
145
|
+
end = len(text) if end == -1 else end
|
|
146
|
+
raw = text[assignment_end:end]
|
|
147
|
+
comment = re.search(r"\s#", raw)
|
|
148
|
+
return (raw if comment is None else raw[: comment.start()]).strip(), end
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def parse_environment_file(text):
|
|
152
|
+
values = {}
|
|
153
|
+
position = 0
|
|
154
|
+
while position < len(text):
|
|
155
|
+
end = text.find("\n", position)
|
|
156
|
+
end = len(text) if end == -1 else end
|
|
157
|
+
line = text[position:end]
|
|
158
|
+
match = ENV_ASSIGNMENT.match(line)
|
|
159
|
+
if match is None or line.lstrip().startswith("#"):
|
|
160
|
+
position = end + 1
|
|
161
|
+
continue
|
|
162
|
+
value, consumed = read_environment_value(text, position + match.end(), match[1])
|
|
163
|
+
values[match[1]] = value
|
|
164
|
+
newline = text.find("\n", consumed)
|
|
165
|
+
position = len(text) if newline == -1 else newline + 1
|
|
166
|
+
return values
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def configure_secrets(argv):
|
|
170
|
+
parser = argparse.ArgumentParser(
|
|
171
|
+
prog="bitfab-replay --cloud-secrets",
|
|
172
|
+
description="Copy named values from local environment files into GitHub Actions secrets. Values are piped to gh on standard input and never printed, logged, or passed as arguments.",
|
|
173
|
+
)
|
|
174
|
+
parser.add_argument(
|
|
175
|
+
"names",
|
|
176
|
+
nargs="*",
|
|
177
|
+
help="Environment variable names to copy; defaults to the secrets recorded by setup",
|
|
178
|
+
)
|
|
179
|
+
parser.add_argument(
|
|
180
|
+
"--env-file",
|
|
181
|
+
action="append",
|
|
182
|
+
required=True,
|
|
183
|
+
help="Repository-relative environment file to read; repeatable, earliest definition wins",
|
|
184
|
+
)
|
|
185
|
+
parser.add_argument(
|
|
186
|
+
"--environment",
|
|
187
|
+
help="Set GitHub Environment secrets instead of repository ones",
|
|
188
|
+
)
|
|
189
|
+
parser.add_argument(
|
|
190
|
+
"--dry-run",
|
|
191
|
+
action="store_true",
|
|
192
|
+
help="Report which names were found without writing anything to GitHub",
|
|
193
|
+
)
|
|
194
|
+
args = parser.parse_args(argv)
|
|
195
|
+
root = root_directory()
|
|
196
|
+
config = configuration(root)
|
|
197
|
+
prefix = config.get("secretPrefix", "")
|
|
198
|
+
names = args.names or config.get("secrets") or ["BITFAB_API_KEY"]
|
|
199
|
+
if not all(re.fullmatch(r"[A-Z_][A-Z0-9_]*", name) for name in names):
|
|
200
|
+
raise ValueError("Secret names must be uppercase environment variable names")
|
|
201
|
+
values = {}
|
|
202
|
+
for path in args.env_file:
|
|
203
|
+
for key, value in parse_environment_file(
|
|
204
|
+
within(root, path).read_text()
|
|
205
|
+
).items():
|
|
206
|
+
values.setdefault(key, value)
|
|
207
|
+
repo = repository(root)
|
|
208
|
+
assigned = []
|
|
209
|
+
missing = []
|
|
210
|
+
empty = []
|
|
211
|
+
for name in names:
|
|
212
|
+
value = values.get(name)
|
|
213
|
+
if value is None:
|
|
214
|
+
missing.append(name)
|
|
215
|
+
continue
|
|
216
|
+
if not value:
|
|
217
|
+
empty.append(name)
|
|
218
|
+
continue
|
|
219
|
+
target = prefix + name
|
|
220
|
+
if not args.dry_run:
|
|
221
|
+
command(
|
|
222
|
+
[
|
|
223
|
+
"gh",
|
|
224
|
+
"secret",
|
|
225
|
+
"set",
|
|
226
|
+
target,
|
|
227
|
+
"--repo",
|
|
228
|
+
repo,
|
|
229
|
+
*(["--env", args.environment] if args.environment else []),
|
|
230
|
+
],
|
|
231
|
+
input=value,
|
|
232
|
+
)
|
|
233
|
+
assigned.append(target)
|
|
234
|
+
return {
|
|
235
|
+
"repository": repo,
|
|
236
|
+
"environment": args.environment,
|
|
237
|
+
"dryRun": args.dry_run,
|
|
238
|
+
"set": assigned,
|
|
239
|
+
"missing": missing,
|
|
240
|
+
"empty": empty,
|
|
241
|
+
"next": "Create the missing secrets by hand; empty local values were skipped because an empty secret overrides a working default with nothing; a wrong value only surfaces when the first real replay runs",
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def validate_config(config):
|
|
246
|
+
if config.get("version") != 1 or config.get("provider") != "github":
|
|
247
|
+
raise ValueError("Run bitfab:setup cloud to configure the GitHub provider")
|
|
248
|
+
if not re.fullmatch(r"[\w-]+\.ya?ml", config.get("workflow", "")):
|
|
249
|
+
raise ValueError("Invalid workflow filename")
|
|
250
|
+
relative_path(config["workingDirectory"])
|
|
251
|
+
if config.get("registry") is not None:
|
|
252
|
+
relative_path(config["registry"])
|
|
253
|
+
pipeline = config.get("pipeline")
|
|
254
|
+
if pipeline is not None and not (
|
|
255
|
+
isinstance(pipeline, str) and PIPELINE.fullmatch(pipeline)
|
|
256
|
+
):
|
|
257
|
+
raise ValueError("Invalid pipeline name")
|
|
258
|
+
args = config.get("command")
|
|
259
|
+
if (
|
|
260
|
+
not isinstance(args, list)
|
|
261
|
+
or not args
|
|
262
|
+
or not all(isinstance(v, str) and v and "\x00" not in v for v in args)
|
|
263
|
+
):
|
|
264
|
+
raise ValueError("command must be a nonempty JSON argument array")
|
|
265
|
+
if any(v.startswith("--cloud") for v in args):
|
|
266
|
+
raise ValueError(
|
|
267
|
+
"The runner command must execute locally, not recursively dispatch"
|
|
268
|
+
)
|
|
269
|
+
if config.get("pushTriggersReviewed") is not True:
|
|
270
|
+
raise ValueError(
|
|
271
|
+
"Setup must review push-triggered CI/deployments and set pushTriggersReviewed=true"
|
|
272
|
+
)
|
|
273
|
+
prefix = config.get("secretPrefix", "")
|
|
274
|
+
if not isinstance(prefix, str) or (
|
|
275
|
+
prefix and not re.fullmatch(r"[A-Z][A-Z0-9_]*_", prefix)
|
|
276
|
+
):
|
|
277
|
+
raise ValueError(
|
|
278
|
+
"secretPrefix must be uppercase and end with an underscore, such as BITFAB_CLOUD_"
|
|
279
|
+
)
|
|
280
|
+
names = config.get("secrets", [])
|
|
281
|
+
if not isinstance(names, list) or not all(
|
|
282
|
+
isinstance(name, str) and re.fullmatch(r"[A-Z_][A-Z0-9_]*", name)
|
|
283
|
+
for name in names
|
|
284
|
+
):
|
|
285
|
+
raise ValueError("secrets must be uppercase environment variable names")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def check_pipeline(config, pipeline):
|
|
289
|
+
if not isinstance(pipeline, str) or not PIPELINE.fullmatch(pipeline):
|
|
290
|
+
raise ValueError("Invalid pipeline name")
|
|
291
|
+
allowed = config.get("pipeline")
|
|
292
|
+
if allowed is not None and pipeline != allowed:
|
|
293
|
+
raise ValueError("Pipeline does not match .bitfab/cloud.json")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def gh(repo, *args, payload=None):
|
|
297
|
+
return command(
|
|
298
|
+
[
|
|
299
|
+
"gh",
|
|
300
|
+
"api",
|
|
301
|
+
"--hostname",
|
|
302
|
+
"github.com",
|
|
303
|
+
"-H",
|
|
304
|
+
f"X-GitHub-Api-Version: {API_VERSION}",
|
|
305
|
+
"--jq",
|
|
306
|
+
"tojson",
|
|
307
|
+
*args,
|
|
308
|
+
*(["--input", "-"] if payload is not None else []),
|
|
309
|
+
],
|
|
310
|
+
input=json.dumps(payload) if payload is not None else None,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def api(repo, suffix, *, method="GET", payload=None):
|
|
315
|
+
result = gh(
|
|
316
|
+
repo,
|
|
317
|
+
f"repos/{repo}" + (f"/{suffix}" if suffix else ""),
|
|
318
|
+
"--method",
|
|
319
|
+
method,
|
|
320
|
+
payload=payload,
|
|
321
|
+
)
|
|
322
|
+
return json.loads(result) if result else None
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def parse(argv):
|
|
326
|
+
flags = [value.split("=", 1)[0] for value in argv if value.startswith("--")]
|
|
327
|
+
if any(flags.count(flag) > 1 for flag in flags if flag != "--cloud-include"):
|
|
328
|
+
raise ValueError("Duplicate cloud option")
|
|
329
|
+
parser = argparse.ArgumentParser(
|
|
330
|
+
description=HELP,
|
|
331
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
332
|
+
allow_abbrev=False,
|
|
333
|
+
)
|
|
334
|
+
parser.add_argument("pipeline", nargs="?")
|
|
335
|
+
parser.add_argument("--registry")
|
|
336
|
+
parser.add_argument("--cloud", action="store_true")
|
|
337
|
+
for name in ("status", "watch", "cancel", "cleanup"):
|
|
338
|
+
parser.add_argument(f"--cloud-{name}")
|
|
339
|
+
parser.add_argument("--trace-ids")
|
|
340
|
+
parser.add_argument("--max-concurrency", type=int, default=1)
|
|
341
|
+
parser.add_argument("--cloud-request-id")
|
|
342
|
+
parser.add_argument("--cloud-include", action="append", default=[])
|
|
343
|
+
parser.add_argument("--cloud-dry-run", action="store_true")
|
|
344
|
+
parser.add_argument("--cloud-detach", action="store_true")
|
|
345
|
+
args = parser.parse_args(argv)
|
|
346
|
+
operations = [
|
|
347
|
+
name
|
|
348
|
+
for name in ("status", "watch", "cancel", "cleanup")
|
|
349
|
+
if getattr(args, "cloud_" + name)
|
|
350
|
+
]
|
|
351
|
+
if operations:
|
|
352
|
+
if len(operations) != 1 or len(argv) != 2:
|
|
353
|
+
raise ValueError("Cloud lifecycle commands take only their execution UUID")
|
|
354
|
+
operation = operations[0]
|
|
355
|
+
execution_id = getattr(args, "cloud_" + operation)
|
|
356
|
+
else:
|
|
357
|
+
if not args.cloud or not args.pipeline or not args.trace_ids:
|
|
358
|
+
raise ValueError(HELP)
|
|
359
|
+
operation = "submit"
|
|
360
|
+
execution_id = args.cloud_request_id or str(uuid.uuid4())
|
|
361
|
+
traces = args.trace_ids.split(",")
|
|
362
|
+
if not 1 <= len(traces) <= 100 or not all(UUID.fullmatch(t) for t in traces):
|
|
363
|
+
raise ValueError("Supply 1..100 explicit trace UUIDs")
|
|
364
|
+
if not 1 <= args.max_concurrency <= 32:
|
|
365
|
+
raise ValueError("--max-concurrency must be 1..32")
|
|
366
|
+
if not UUID.fullmatch(execution_id):
|
|
367
|
+
raise ValueError("Execution ID must be a UUID")
|
|
368
|
+
return args, operation, execution_id
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def sensitive(path):
|
|
372
|
+
parts = Path(path).parts
|
|
373
|
+
name = Path(path).name.lower()
|
|
374
|
+
return (
|
|
375
|
+
any(part in (".git", ".ssh", ".aws") for part in parts)
|
|
376
|
+
or name == ".env"
|
|
377
|
+
or (
|
|
378
|
+
name.startswith(".env.")
|
|
379
|
+
and name not in (".env.example", ".env.sample", ".env.template")
|
|
380
|
+
)
|
|
381
|
+
or name in (".npmrc", ".pypirc", ".netrc", "id_rsa", "id_ed25519")
|
|
382
|
+
or name.endswith((".pem", ".key", ".p12", ".pfx", ".local.json"))
|
|
383
|
+
or name
|
|
384
|
+
in (
|
|
385
|
+
"credentials",
|
|
386
|
+
"credentials.json",
|
|
387
|
+
"secrets.json",
|
|
388
|
+
"secrets.yml",
|
|
389
|
+
"secrets.yaml",
|
|
390
|
+
)
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def snapshot(root, config, args, execution_id):
|
|
395
|
+
if git(root, "ls-files", "-u"):
|
|
396
|
+
raise ValueError("Resolve merge conflicts before snapshotting")
|
|
397
|
+
head = git(root, "rev-parse", "HEAD")
|
|
398
|
+
includes = []
|
|
399
|
+
for value in args.cloud_include:
|
|
400
|
+
path = within(root, value)
|
|
401
|
+
if not path.is_file() or path.is_symlink() or (root / value).is_symlink():
|
|
402
|
+
raise ValueError("--cloud-include requires individual regular files")
|
|
403
|
+
# Never force-add ignored content, even when explicitly requested.
|
|
404
|
+
ignored = subprocess.run(
|
|
405
|
+
["git", "-C", str(root), "check-ignore", "--quiet", "--", value],
|
|
406
|
+
check=False,
|
|
407
|
+
timeout=15,
|
|
408
|
+
).returncode
|
|
409
|
+
if ignored not in (0, 1):
|
|
410
|
+
raise ValueError("Unable to determine whether the included file is ignored")
|
|
411
|
+
if ignored == 0 or sensitive(value):
|
|
412
|
+
raise ValueError(f"Refusing ignored or credential-like file: {value}")
|
|
413
|
+
includes.append(value)
|
|
414
|
+
with tempfile.TemporaryDirectory(prefix="bitfab-cloud-index-") as directory:
|
|
415
|
+
env = {**os.environ, "GIT_INDEX_FILE": str(Path(directory) / "index")}
|
|
416
|
+
git(root, "read-tree", head, env=env)
|
|
417
|
+
git(root, "add", "-u", "--", ".", env=env)
|
|
418
|
+
if includes:
|
|
419
|
+
git(root, "--literal-pathspecs", "add", "--", *includes, env=env)
|
|
420
|
+
entries = git(root, "ls-files", "--stage", "-z", env=env).split("\x00")
|
|
421
|
+
for entry in filter(None, entries):
|
|
422
|
+
metadata, path = entry.split("\t", 1)
|
|
423
|
+
if metadata.startswith("160000"):
|
|
424
|
+
raise ValueError(
|
|
425
|
+
"Submodules/gitlinks require explicit setup support before cloud snapshots"
|
|
426
|
+
)
|
|
427
|
+
if sensitive(path):
|
|
428
|
+
raise ValueError(
|
|
429
|
+
f"Refusing credential-like tracked file in snapshot: {path}"
|
|
430
|
+
)
|
|
431
|
+
required = [CONFIG, f".github/workflows/{config['workflow']}"]
|
|
432
|
+
if config.get("registry") is not None:
|
|
433
|
+
required.append(config["registry"])
|
|
434
|
+
tracked = set(
|
|
435
|
+
git(
|
|
436
|
+
root, "--literal-pathspecs", "ls-files", "-z", "--", *required, env=env
|
|
437
|
+
).split("\x00")
|
|
438
|
+
)
|
|
439
|
+
untracked = [path for path in required if path not in tracked]
|
|
440
|
+
if untracked:
|
|
441
|
+
raise ValueError(
|
|
442
|
+
"Not in the snapshot: "
|
|
443
|
+
+ ", ".join(untracked)
|
|
444
|
+
+ ". Commit these files, or pass --cloud-include for each one"
|
|
445
|
+
)
|
|
446
|
+
tree = git(root, "write-tree", env=env)
|
|
447
|
+
files = git(root, "diff", "--name-only", head, tree).splitlines()
|
|
448
|
+
if args.cloud_dry_run:
|
|
449
|
+
new_files = set(
|
|
450
|
+
git(root, "ls-files", "--others", "--exclude-standard").splitlines()
|
|
451
|
+
)
|
|
452
|
+
new_files.update(
|
|
453
|
+
git(
|
|
454
|
+
root, "diff", "--cached", "--name-only", "--diff-filter=A"
|
|
455
|
+
).splitlines()
|
|
456
|
+
)
|
|
457
|
+
return {
|
|
458
|
+
"baseSha": head,
|
|
459
|
+
"files": files,
|
|
460
|
+
"includedFiles": includes,
|
|
461
|
+
"omittedNewFiles": sorted(new_files - set(includes)),
|
|
462
|
+
}
|
|
463
|
+
# commit-tree adds an unreachable object, never touching HEAD or the real index.
|
|
464
|
+
sha = git(
|
|
465
|
+
root,
|
|
466
|
+
"commit-tree",
|
|
467
|
+
tree,
|
|
468
|
+
"-p",
|
|
469
|
+
head,
|
|
470
|
+
input=f"Bitfab replay {execution_id}\n",
|
|
471
|
+
)
|
|
472
|
+
return {"baseSha": head, "sha": sha, "files": files}
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def state_directory(root):
|
|
476
|
+
path = Path(git(root, "rev-parse", "--absolute-git-dir")) / "bitfab-cloud"
|
|
477
|
+
path.mkdir(mode=0o700, exist_ok=True)
|
|
478
|
+
return path
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def save(path, record):
|
|
482
|
+
with tempfile.NamedTemporaryFile(mode="w", dir=path.parent, delete=False) as file:
|
|
483
|
+
json.dump(record, file)
|
|
484
|
+
file.write("\n")
|
|
485
|
+
name = file.name
|
|
486
|
+
os.replace(name, path)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
@contextlib.contextmanager
|
|
490
|
+
def execution_lock(directory, execution_id):
|
|
491
|
+
import fcntl
|
|
492
|
+
|
|
493
|
+
with (directory / f"{execution_id}.lock").open("a") as lock:
|
|
494
|
+
try:
|
|
495
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
496
|
+
except BlockingIOError as error:
|
|
497
|
+
raise ValueError(
|
|
498
|
+
"Another command is operating on this cloud execution"
|
|
499
|
+
) from error
|
|
500
|
+
yield
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def find_run(record):
|
|
504
|
+
query = urlencode(
|
|
505
|
+
{"event": "workflow_dispatch", "branch": record["branch"], "per_page": 100}
|
|
506
|
+
)
|
|
507
|
+
runs = api(
|
|
508
|
+
record["repository"], f"actions/workflows/{record['workflow']}/runs?{query}"
|
|
509
|
+
)
|
|
510
|
+
matches = [
|
|
511
|
+
run
|
|
512
|
+
for run in runs["workflow_runs"]
|
|
513
|
+
if run["head_sha"] == record["sha"]
|
|
514
|
+
and run.get("display_title") == f"Bitfab replay {record['id']}"
|
|
515
|
+
]
|
|
516
|
+
if len(matches) > 1:
|
|
517
|
+
raise ValueError(
|
|
518
|
+
"Multiple matching GitHub runs; inspect Actions before cleanup"
|
|
519
|
+
)
|
|
520
|
+
return matches[0] if matches else None
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def status(record, *, fetch_result=True):
|
|
524
|
+
if record["state"] == "prepared":
|
|
525
|
+
return record
|
|
526
|
+
run = (
|
|
527
|
+
api(record["repository"], f"actions/runs/{record['runId']}")
|
|
528
|
+
if record.get("runId")
|
|
529
|
+
else find_run(record)
|
|
530
|
+
)
|
|
531
|
+
if run is None:
|
|
532
|
+
record["state"] = "dispatch_unknown"
|
|
533
|
+
return record
|
|
534
|
+
if run["head_sha"] != record["sha"] or run["head_branch"] != record["branch"]:
|
|
535
|
+
raise ValueError("GitHub run does not match the recorded execution")
|
|
536
|
+
if run.get("display_title") != f"Bitfab replay {record['id']}":
|
|
537
|
+
if run["status"] == "completed":
|
|
538
|
+
raise ValueError("GitHub run does not match the recorded execution")
|
|
539
|
+
record.update(runId=run["id"], url=run["html_url"], state=run["status"])
|
|
540
|
+
return record
|
|
541
|
+
record.update(
|
|
542
|
+
runId=run["id"],
|
|
543
|
+
url=run["html_url"],
|
|
544
|
+
state=run["status"],
|
|
545
|
+
conclusion=run.get("conclusion"),
|
|
546
|
+
)
|
|
547
|
+
if (
|
|
548
|
+
fetch_result
|
|
549
|
+
and record["state"] == "completed"
|
|
550
|
+
and record["conclusion"] == "success"
|
|
551
|
+
and not record.get("testRunId")
|
|
552
|
+
):
|
|
553
|
+
with tempfile.TemporaryDirectory(prefix="bitfab-cloud-result-") as directory:
|
|
554
|
+
command(
|
|
555
|
+
[
|
|
556
|
+
"gh",
|
|
557
|
+
"run",
|
|
558
|
+
"download",
|
|
559
|
+
str(record["runId"]),
|
|
560
|
+
"--repo",
|
|
561
|
+
record["repository"],
|
|
562
|
+
"--name",
|
|
563
|
+
"bitfab-replay-" + record["id"],
|
|
564
|
+
"--dir",
|
|
565
|
+
directory,
|
|
566
|
+
]
|
|
567
|
+
)
|
|
568
|
+
artifact = Path(directory) / "bitfab-cloud-result.json"
|
|
569
|
+
if artifact.is_symlink() or artifact.stat().st_size > 4096:
|
|
570
|
+
raise ValueError("Invalid replay result artifact")
|
|
571
|
+
result = json.loads(artifact.read_text())
|
|
572
|
+
if (
|
|
573
|
+
result.get("executionId") != record["id"]
|
|
574
|
+
or result.get("commitSha") != record["sha"]
|
|
575
|
+
or not UUID.fullmatch(result.get("testRunId", ""))
|
|
576
|
+
):
|
|
577
|
+
raise ValueError("Replay result artifact does not match this execution")
|
|
578
|
+
counts = replay_counts(result)
|
|
579
|
+
record["testRunId"] = result["testRunId"]
|
|
580
|
+
if counts is not None:
|
|
581
|
+
record["replayed"], record["errored"] = counts
|
|
582
|
+
return record
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def cleanup(root, record):
|
|
586
|
+
if record.get("cleaned"):
|
|
587
|
+
return
|
|
588
|
+
if record.get("state") not in ("completed", "prepared"):
|
|
589
|
+
raise ValueError(
|
|
590
|
+
"Cleanup requires a confirmed completed GitHub run; cancel and wait first"
|
|
591
|
+
)
|
|
592
|
+
if record["branch"] != PREFIX + record["id"] or not SHA.fullmatch(record["sha"]):
|
|
593
|
+
raise ValueError("Refusing cleanup of an unowned branch")
|
|
594
|
+
ref = "refs/heads/" + record["branch"]
|
|
595
|
+
remote = git(root, "ls-remote", "--heads", "origin", ref)
|
|
596
|
+
if remote:
|
|
597
|
+
if remote.split()[0] != record["sha"]:
|
|
598
|
+
raise ValueError(
|
|
599
|
+
"Snapshot branch moved; refusing to delete someone else's changes"
|
|
600
|
+
)
|
|
601
|
+
git(
|
|
602
|
+
root,
|
|
603
|
+
"push",
|
|
604
|
+
f"--force-with-lease={ref}:{record['sha']}",
|
|
605
|
+
"origin",
|
|
606
|
+
f":{ref}",
|
|
607
|
+
)
|
|
608
|
+
record["cleaned"] = True
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def preflight(repo, config):
|
|
612
|
+
info = api(repo, "")
|
|
613
|
+
workflow = api(repo, f"actions/workflows/{config['workflow']}")
|
|
614
|
+
if workflow.get("state") != "active":
|
|
615
|
+
raise ValueError("Replay workflow must be active")
|
|
616
|
+
api(
|
|
617
|
+
repo,
|
|
618
|
+
f"contents/.github/workflows/{config['workflow']}?"
|
|
619
|
+
+ urlencode({"ref": info["default_branch"]}),
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def run_cli(argv):
|
|
624
|
+
args, operation, execution_id = parse(argv)
|
|
625
|
+
if os.name != "posix" or sys.version_info < (3, 10):
|
|
626
|
+
raise ValueError("Cloud replay currently requires macOS/Linux and Python 3.10+")
|
|
627
|
+
root = root_directory()
|
|
628
|
+
config = configuration(root) if operation == "submit" else None
|
|
629
|
+
repo = repository(root)
|
|
630
|
+
directory = state_directory(root)
|
|
631
|
+
path = directory / f"{execution_id}.json"
|
|
632
|
+
with execution_lock(directory, execution_id):
|
|
633
|
+
if operation == "submit":
|
|
634
|
+
within(root, config["workingDirectory"])
|
|
635
|
+
check_pipeline(config, args.pipeline)
|
|
636
|
+
if args.registry is not None:
|
|
637
|
+
registry = Path(args.registry).resolve().relative_to(root).as_posix()
|
|
638
|
+
if registry != config.get("registry"):
|
|
639
|
+
raise ValueError("Registry does not match .bitfab/cloud.json")
|
|
640
|
+
request = {
|
|
641
|
+
"id": execution_id,
|
|
642
|
+
"pipeline": args.pipeline,
|
|
643
|
+
"traceIds": args.trace_ids.split(","),
|
|
644
|
+
"maxConcurrency": args.max_concurrency,
|
|
645
|
+
}
|
|
646
|
+
if args.cloud_dry_run:
|
|
647
|
+
return {
|
|
648
|
+
"dryRun": True,
|
|
649
|
+
"repository": repo,
|
|
650
|
+
**snapshot(root, config, args, execution_id),
|
|
651
|
+
}
|
|
652
|
+
if path.exists():
|
|
653
|
+
record = json.loads(path.read_text())
|
|
654
|
+
if (
|
|
655
|
+
record["request"] != request
|
|
656
|
+
or record["repository"].lower() != repo.lower()
|
|
657
|
+
):
|
|
658
|
+
raise ValueError(
|
|
659
|
+
"Execution ID already belongs to a different request"
|
|
660
|
+
)
|
|
661
|
+
# Never redispatch an uncertain request, even after a lost response.
|
|
662
|
+
status(record)
|
|
663
|
+
if record["state"] == "prepared":
|
|
664
|
+
raise ValueError(
|
|
665
|
+
"Submission stopped before dispatch. Use --cloud-cleanup, then submit a new execution UUID"
|
|
666
|
+
)
|
|
667
|
+
else:
|
|
668
|
+
command(["gh", "auth", "status", "--hostname", "github.com"])
|
|
669
|
+
preflight(repo, config)
|
|
670
|
+
source = snapshot(root, config, args, execution_id)
|
|
671
|
+
record = {
|
|
672
|
+
"id": execution_id,
|
|
673
|
+
"repository": repo,
|
|
674
|
+
"workflow": config["workflow"],
|
|
675
|
+
"branch": PREFIX + execution_id,
|
|
676
|
+
"request": request,
|
|
677
|
+
"state": "prepared",
|
|
678
|
+
**source,
|
|
679
|
+
}
|
|
680
|
+
save(path, record)
|
|
681
|
+
print(
|
|
682
|
+
f"Cloud execution {execution_id}. Recover with --cloud-status {execution_id}",
|
|
683
|
+
file=sys.stderr,
|
|
684
|
+
flush=True,
|
|
685
|
+
)
|
|
686
|
+
ref = "refs/heads/" + record["branch"]
|
|
687
|
+
# Empty lease asserts that the temporary remote branch does not exist.
|
|
688
|
+
git(
|
|
689
|
+
root,
|
|
690
|
+
"push",
|
|
691
|
+
f"--force-with-lease={ref}:",
|
|
692
|
+
"origin",
|
|
693
|
+
f"{record['sha']}:{ref}",
|
|
694
|
+
)
|
|
695
|
+
record["state"] = "dispatch_unknown"
|
|
696
|
+
save(path, record)
|
|
697
|
+
encoded = base64.b64encode(json.dumps(request).encode()).decode()
|
|
698
|
+
response = api(
|
|
699
|
+
repo,
|
|
700
|
+
f"actions/workflows/{config['workflow']}/dispatches",
|
|
701
|
+
method="POST",
|
|
702
|
+
payload={
|
|
703
|
+
"ref": record["branch"],
|
|
704
|
+
"inputs": {"execution_id": execution_id, "request": encoded},
|
|
705
|
+
},
|
|
706
|
+
)
|
|
707
|
+
if isinstance(response, dict) and response.get("workflow_run_id"):
|
|
708
|
+
record["runId"] = response["workflow_run_id"]
|
|
709
|
+
record["url"] = response.get("html_url")
|
|
710
|
+
record["state"] = "queued"
|
|
711
|
+
else:
|
|
712
|
+
if not path.exists():
|
|
713
|
+
raise ValueError(
|
|
714
|
+
"No local execution record for this UUID in this worktree"
|
|
715
|
+
)
|
|
716
|
+
record = json.loads(path.read_text())
|
|
717
|
+
if record["repository"].lower() != repo.lower():
|
|
718
|
+
raise ValueError("origin no longer matches the execution repository")
|
|
719
|
+
status(record, fetch_result=operation != "cleanup")
|
|
720
|
+
if operation == "cancel" and record["state"] != "completed":
|
|
721
|
+
if not record.get("runId"):
|
|
722
|
+
raise ValueError(
|
|
723
|
+
"Run not yet found; inspect Actions and retry status before cancelling"
|
|
724
|
+
)
|
|
725
|
+
api(repo, f"actions/runs/{record['runId']}/cancel", method="POST")
|
|
726
|
+
record["state"] = "cancel_requested"
|
|
727
|
+
save(path, record)
|
|
728
|
+
if record["state"] == "prepared" and operation != "cleanup":
|
|
729
|
+
save(path, record)
|
|
730
|
+
return record
|
|
731
|
+
if operation == "watch" or (operation == "submit" and not args.cloud_detach):
|
|
732
|
+
deadline = time.monotonic() + 40 * 60
|
|
733
|
+
while record["state"] != "completed" and time.monotonic() < deadline:
|
|
734
|
+
status(record)
|
|
735
|
+
save(path, record)
|
|
736
|
+
if record["state"] != "completed":
|
|
737
|
+
time.sleep(5)
|
|
738
|
+
if record["state"] != "completed":
|
|
739
|
+
raise ValueError(
|
|
740
|
+
"Watch timed out; the job may still run. Resume with --cloud-watch "
|
|
741
|
+
+ execution_id
|
|
742
|
+
)
|
|
743
|
+
if record["state"] == "completed" or (
|
|
744
|
+
operation == "cleanup" and record["state"] == "prepared"
|
|
745
|
+
):
|
|
746
|
+
cleanup(root, record)
|
|
747
|
+
elif operation == "cleanup":
|
|
748
|
+
raise ValueError("Execution is not completed; no branch was deleted")
|
|
749
|
+
save(path, record)
|
|
750
|
+
return record
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def execute():
|
|
754
|
+
if os.environ.get("GITHUB_RUN_ATTEMPT") != "1":
|
|
755
|
+
raise ValueError("Submit a new replay instead of rerunning an Actions job")
|
|
756
|
+
request = json.loads(
|
|
757
|
+
base64.b64decode(os.environ["BITFAB_CLOUD_REQUEST"], validate=True)
|
|
758
|
+
)
|
|
759
|
+
root = root_directory()
|
|
760
|
+
if git(root, "rev-parse", "HEAD") != os.environ["GITHUB_SHA"]:
|
|
761
|
+
raise ValueError("Runner checkout does not match the dispatched snapshot SHA")
|
|
762
|
+
config = configuration(root)
|
|
763
|
+
check_pipeline(config, request["pipeline"])
|
|
764
|
+
if request["id"] != os.environ["BITFAB_EXECUTION_ID"]:
|
|
765
|
+
raise ValueError("Replay request does not match the configured execution")
|
|
766
|
+
parse(
|
|
767
|
+
[
|
|
768
|
+
"--cloud",
|
|
769
|
+
request["pipeline"],
|
|
770
|
+
"--trace-ids",
|
|
771
|
+
",".join(request["traceIds"]),
|
|
772
|
+
"--max-concurrency",
|
|
773
|
+
str(request["maxConcurrency"]),
|
|
774
|
+
"--cloud-request-id",
|
|
775
|
+
request["id"],
|
|
776
|
+
]
|
|
777
|
+
)
|
|
778
|
+
if not os.environ.get("BITFAB_API_KEY"):
|
|
779
|
+
raise ValueError("Configure the BITFAB_API_KEY GitHub secret")
|
|
780
|
+
args = [
|
|
781
|
+
*config["command"],
|
|
782
|
+
request["pipeline"],
|
|
783
|
+
"--trace-ids",
|
|
784
|
+
",".join(request["traceIds"]),
|
|
785
|
+
"--max-concurrency",
|
|
786
|
+
str(request["maxConcurrency"]),
|
|
787
|
+
"--no-code-change",
|
|
788
|
+
]
|
|
789
|
+
with tempfile.TemporaryFile() as output:
|
|
790
|
+
with subprocess.Popen(
|
|
791
|
+
args,
|
|
792
|
+
cwd=within(root, config["workingDirectory"]),
|
|
793
|
+
stdout=output,
|
|
794
|
+
stdin=subprocess.DEVNULL,
|
|
795
|
+
start_new_session=True,
|
|
796
|
+
) as child:
|
|
797
|
+
try:
|
|
798
|
+
deadline = time.monotonic() + 25 * 60
|
|
799
|
+
while child.poll() is None:
|
|
800
|
+
if os.fstat(output.fileno()).st_size > 16 * 1024 * 1024:
|
|
801
|
+
raise ValueError("Replay output exceeded 16 MiB")
|
|
802
|
+
if time.monotonic() >= deadline:
|
|
803
|
+
raise subprocess.TimeoutExpired(args, 25 * 60)
|
|
804
|
+
time.sleep(0.1)
|
|
805
|
+
code = child.returncode
|
|
806
|
+
except BaseException:
|
|
807
|
+
with contextlib.suppress(ProcessLookupError):
|
|
808
|
+
os.killpg(child.pid, signal.SIGTERM)
|
|
809
|
+
with contextlib.suppress(subprocess.TimeoutExpired):
|
|
810
|
+
child.wait(timeout=10)
|
|
811
|
+
with contextlib.suppress(ProcessLookupError):
|
|
812
|
+
os.killpg(child.pid, signal.SIGKILL)
|
|
813
|
+
child.wait()
|
|
814
|
+
raise
|
|
815
|
+
if output.tell() > 16 * 1024 * 1024:
|
|
816
|
+
raise ValueError("Replay output exceeded 16 MiB")
|
|
817
|
+
output.seek(0)
|
|
818
|
+
text = output.read().decode()
|
|
819
|
+
if code:
|
|
820
|
+
raise ValueError(f"Replay command exited {code}; inspect the job logs")
|
|
821
|
+
decoder = json.JSONDecoder()
|
|
822
|
+
result = None
|
|
823
|
+
for offset in [0, *[i + 1 for i, value in enumerate(text) if value == "\n"]]:
|
|
824
|
+
if text.startswith("{", offset):
|
|
825
|
+
try:
|
|
826
|
+
value, end = decoder.raw_decode(text, offset)
|
|
827
|
+
if not text[end:].strip() and isinstance(value, dict):
|
|
828
|
+
result = value
|
|
829
|
+
except json.JSONDecodeError:
|
|
830
|
+
pass
|
|
831
|
+
test_run = (
|
|
832
|
+
None if result is None else result.get("testRunId", result.get("test_run_id"))
|
|
833
|
+
)
|
|
834
|
+
if not isinstance(test_run, str) or not UUID.fullmatch(test_run):
|
|
835
|
+
raise ValueError("Replay did not return a valid persisted test run UUID")
|
|
836
|
+
items = result.get("items")
|
|
837
|
+
if not isinstance(items, list):
|
|
838
|
+
raise ValueError("Replay did not return its replayed items")
|
|
839
|
+
summary = {
|
|
840
|
+
"executionId": request["id"],
|
|
841
|
+
"commitSha": os.environ["GITHUB_SHA"],
|
|
842
|
+
"testRunId": test_run,
|
|
843
|
+
"replayed": len(items),
|
|
844
|
+
"errored": sum(1 for item in items if item_errored(item)),
|
|
845
|
+
}
|
|
846
|
+
(Path(os.environ["RUNNER_TEMP"]) / "bitfab-cloud-result.json").write_text(
|
|
847
|
+
json.dumps(summary) + "\n"
|
|
848
|
+
)
|
|
849
|
+
with Path(os.environ["GITHUB_STEP_SUMMARY"]).open("a") as file:
|
|
850
|
+
file.write(
|
|
851
|
+
f"### Bitfab replay\n\nTest run: `{test_run}`\n\nCommit: `{os.environ['GITHUB_SHA']}`\n\n"
|
|
852
|
+
f"Replayed: {summary['replayed']}, errored: {summary['errored']}\n"
|
|
853
|
+
)
|
|
854
|
+
return summary
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def item_errored(item):
|
|
858
|
+
return isinstance(item, dict) and any(
|
|
859
|
+
item.get(field) is not None for field in ITEM_ERROR_FIELDS
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def replay_counts(result):
|
|
864
|
+
replayed, errored = result.get("replayed"), result.get("errored")
|
|
865
|
+
if replayed is None and errored is None:
|
|
866
|
+
return None
|
|
867
|
+
if (
|
|
868
|
+
not all(
|
|
869
|
+
isinstance(value, int) and not isinstance(value, bool) and value >= 0
|
|
870
|
+
for value in (replayed, errored)
|
|
871
|
+
)
|
|
872
|
+
or errored > replayed
|
|
873
|
+
):
|
|
874
|
+
raise ValueError("Replay result artifact has invalid item counts")
|
|
875
|
+
return replayed, errored
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def report_errored_items(record):
|
|
879
|
+
replayed, errored = record.get("replayed"), record.get("errored")
|
|
880
|
+
if not errored:
|
|
881
|
+
return 0
|
|
882
|
+
if errored == replayed:
|
|
883
|
+
print(
|
|
884
|
+
f"Cloud replay: every replayed trace errored ({errored} of {replayed}), so nothing ran successfully. Open test run {record.get('testRunId')} to see why.",
|
|
885
|
+
file=sys.stderr,
|
|
886
|
+
)
|
|
887
|
+
return 1
|
|
888
|
+
print(
|
|
889
|
+
f"Cloud replay: {errored} of {replayed} replayed traces errored. Open test run {record.get('testRunId')} to see why.",
|
|
890
|
+
file=sys.stderr,
|
|
891
|
+
)
|
|
892
|
+
return 0
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def initialize(argv):
|
|
896
|
+
parser = argparse.ArgumentParser(
|
|
897
|
+
prog="bitfab-replay --cloud-init",
|
|
898
|
+
description="Install direct GitHub cloud replay from a reviewed JSON setup specification. Existing files are never overwritten.",
|
|
899
|
+
)
|
|
900
|
+
parser.add_argument(
|
|
901
|
+
"--config",
|
|
902
|
+
required=True,
|
|
903
|
+
help="JSON file with cloud config, cliCommand, setupSteps, optional pipeline (omit or null to allow every registry pipeline), secrets, variables, environment, services, runsOn",
|
|
904
|
+
)
|
|
905
|
+
args = parser.parse_args(argv)
|
|
906
|
+
spec = json.loads(Path(args.config).read_text())
|
|
907
|
+
config = {
|
|
908
|
+
key: spec[key]
|
|
909
|
+
for key in (
|
|
910
|
+
"version",
|
|
911
|
+
"provider",
|
|
912
|
+
"workflow",
|
|
913
|
+
"workingDirectory",
|
|
914
|
+
"registry",
|
|
915
|
+
"command",
|
|
916
|
+
"pushTriggersReviewed",
|
|
917
|
+
)
|
|
918
|
+
}
|
|
919
|
+
config["pipeline"] = spec.get("pipeline")
|
|
920
|
+
secrets = spec.get("secrets", ["BITFAB_API_KEY"])
|
|
921
|
+
variables = spec.get("variables", [])
|
|
922
|
+
secret_prefix = spec.get("secretPrefix")
|
|
923
|
+
if secret_prefix is None:
|
|
924
|
+
secret_prefix = DEFAULT_SECRET_PREFIX
|
|
925
|
+
config["secrets"] = secrets
|
|
926
|
+
config["secretPrefix"] = secret_prefix
|
|
927
|
+
validate_config(config)
|
|
928
|
+
if set(secrets) & set(variables):
|
|
929
|
+
raise ValueError("Secret names and variable names must not overlap")
|
|
930
|
+
if "BITFAB_API_KEY" not in secrets or not all(
|
|
931
|
+
re.fullmatch(r"[A-Z_][A-Z0-9_]*", name) for name in [*secrets, *variables]
|
|
932
|
+
):
|
|
933
|
+
raise ValueError(
|
|
934
|
+
"Supply uppercase secret/variable names, including BITFAB_API_KEY; never values"
|
|
935
|
+
)
|
|
936
|
+
cli_command = spec.get("cliCommand")
|
|
937
|
+
if (
|
|
938
|
+
not isinstance(cli_command, list)
|
|
939
|
+
or not cli_command
|
|
940
|
+
or not all(isinstance(v, str) and v and "\x00" not in v for v in cli_command)
|
|
941
|
+
or any(v.startswith("--cloud") for v in cli_command)
|
|
942
|
+
):
|
|
943
|
+
raise ValueError(
|
|
944
|
+
"cliCommand must be the argument array that starts the SDK's bitfab-replay command from workingDirectory"
|
|
945
|
+
)
|
|
946
|
+
steps = spec.get("setupSteps")
|
|
947
|
+
if (
|
|
948
|
+
not isinstance(steps, list)
|
|
949
|
+
or not steps
|
|
950
|
+
or not all(isinstance(step, dict) for step in steps)
|
|
951
|
+
):
|
|
952
|
+
raise ValueError("setupSteps must contain reviewed GitHub Actions setup steps")
|
|
953
|
+
env = {key: "${{ secrets." + secret_prefix + key + " }}" for key in secrets}
|
|
954
|
+
env.update({key: "${{ vars." + key + " }}" for key in variables})
|
|
955
|
+
env.update(
|
|
956
|
+
BITFAB_CLOUD_REQUEST="${{ inputs.request }}",
|
|
957
|
+
BITFAB_EXECUTION_ID="${{ inputs.execution_id }}",
|
|
958
|
+
BITFAB_COMMIT_SHA="${{ github.sha }}",
|
|
959
|
+
)
|
|
960
|
+
job = {
|
|
961
|
+
"runs-on": spec.get("runsOn", "ubuntu-24.04"),
|
|
962
|
+
"timeout-minutes": 35,
|
|
963
|
+
"steps": [
|
|
964
|
+
{
|
|
965
|
+
"name": "Check out replay snapshot",
|
|
966
|
+
"uses": "actions/checkout@11d5960a326750d5838078e36cf38b85af677262",
|
|
967
|
+
"with": {"ref": "${{ github.sha }}", "persist-credentials": False},
|
|
968
|
+
},
|
|
969
|
+
*steps,
|
|
970
|
+
{
|
|
971
|
+
"name": "Replay",
|
|
972
|
+
"working-directory": config["workingDirectory"],
|
|
973
|
+
"run": shlex.join([*cli_command, "--cloud-execute"]),
|
|
974
|
+
"env": env,
|
|
975
|
+
},
|
|
976
|
+
{
|
|
977
|
+
"name": "Save replay identity",
|
|
978
|
+
"if": "always()",
|
|
979
|
+
"uses": "actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02",
|
|
980
|
+
"with": {
|
|
981
|
+
"name": "bitfab-replay-${{ inputs.execution_id }}",
|
|
982
|
+
"path": "${{ runner.temp }}/bitfab-cloud-result.json",
|
|
983
|
+
"if-no-files-found": "ignore",
|
|
984
|
+
"retention-days": 7,
|
|
985
|
+
},
|
|
986
|
+
},
|
|
987
|
+
],
|
|
988
|
+
}
|
|
989
|
+
for key in ("environment", "services"):
|
|
990
|
+
if key in spec:
|
|
991
|
+
job[key] = spec[key]
|
|
992
|
+
workflow = {
|
|
993
|
+
"name": "Bitfab cloud replay",
|
|
994
|
+
"run-name": "Bitfab replay ${{ inputs.execution_id }}",
|
|
995
|
+
"on": {
|
|
996
|
+
"workflow_dispatch": {
|
|
997
|
+
"inputs": {
|
|
998
|
+
"execution_id": {"required": True, "type": "string"},
|
|
999
|
+
"request": {"required": True, "type": "string"},
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
},
|
|
1003
|
+
"permissions": {"contents": "read"},
|
|
1004
|
+
"jobs": {"replay": job},
|
|
1005
|
+
}
|
|
1006
|
+
root = root_directory()
|
|
1007
|
+
outputs = {
|
|
1008
|
+
CONFIG: json.dumps(config, indent=2) + "\n",
|
|
1009
|
+
f".github/workflows/{config['workflow']}": json.dumps(workflow, indent=2)
|
|
1010
|
+
+ "\n",
|
|
1011
|
+
}
|
|
1012
|
+
for name in outputs:
|
|
1013
|
+
target = within(root, name)
|
|
1014
|
+
if target.exists():
|
|
1015
|
+
raise ValueError(
|
|
1016
|
+
f"Refusing to overwrite {name}; review and edit the existing setup"
|
|
1017
|
+
)
|
|
1018
|
+
for name, content in outputs.items():
|
|
1019
|
+
target = within(root, name)
|
|
1020
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
1021
|
+
with target.open("x") as file:
|
|
1022
|
+
file.write(content)
|
|
1023
|
+
return {
|
|
1024
|
+
"files": list(outputs),
|
|
1025
|
+
"requiredSecrets": [secret_prefix + name for name in secrets],
|
|
1026
|
+
"requiredVariables": variables,
|
|
1027
|
+
"next": "Configure secrets securely, review push triggers, merge the workflow to the default branch, then run --cloud-dry-run",
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def main():
|
|
1032
|
+
try:
|
|
1033
|
+
if sys.argv[1:2] == ["--cloud-init"]:
|
|
1034
|
+
result = initialize(sys.argv[2:])
|
|
1035
|
+
elif sys.argv[1:2] == ["--cloud-secrets"]:
|
|
1036
|
+
result = configure_secrets(sys.argv[2:])
|
|
1037
|
+
elif sys.argv[1:] == ["--cloud-execute"]:
|
|
1038
|
+
result = execute()
|
|
1039
|
+
else:
|
|
1040
|
+
result = run_cli(sys.argv[1:])
|
|
1041
|
+
print(json.dumps(result, indent=2))
|
|
1042
|
+
if result.get("state") == "completed" and result.get("conclusion") != "success":
|
|
1043
|
+
return 1
|
|
1044
|
+
if result.get("state") == "completed":
|
|
1045
|
+
return report_errored_items(result)
|
|
1046
|
+
return 0
|
|
1047
|
+
except KeyboardInterrupt:
|
|
1048
|
+
print(
|
|
1049
|
+
"Detached. The GitHub job continues; use the printed execution UUID to resume or cancel.",
|
|
1050
|
+
file=sys.stderr,
|
|
1051
|
+
)
|
|
1052
|
+
return 130
|
|
1053
|
+
except (
|
|
1054
|
+
ValueError,
|
|
1055
|
+
RuntimeError,
|
|
1056
|
+
OSError,
|
|
1057
|
+
subprocess.SubprocessError,
|
|
1058
|
+
KeyError,
|
|
1059
|
+
) as error:
|
|
1060
|
+
print(f"Cloud replay: {error}", file=sys.stderr)
|
|
1061
|
+
return 1
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
if __name__ == "__main__":
|
|
1065
|
+
raise SystemExit(main())
|