bitfab 0.62.5 → 0.62.7
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 +869 -201
- package/dist/{chunk-UUB5LHWU.js → chunk-ACULRSMF.js} +2 -2
- package/dist/{chunk-UUB5LHWU.js.map → chunk-ACULRSMF.js.map} +1 -1
- package/dist/{chunk-COFCJXSZ.js → chunk-ALEQLDHW.js} +2 -2
- package/dist/{chunk-COFCJXSZ.js.map → chunk-ALEQLDHW.js.map} +1 -1
- package/dist/{chunk-SPGTIHTC.js → chunk-DAHOCXKQ.js} +23 -3
- package/dist/{chunk-SPGTIHTC.js.map → chunk-DAHOCXKQ.js.map} +1 -1
- package/dist/{chunk-SDEDJXKM.js → chunk-S2YDBMPJ.js} +6 -6
- package/dist/{chunk-SDEDJXKM.js.map → chunk-S2YDBMPJ.js.map} +1 -1
- package/dist/{chunk-XADZNIOO.js → chunk-U6S5C7WE.js} +2 -2
- package/dist/{http-LM5MZIOW.js → http-7Z2B7R4F.js} +2 -2
- package/dist/{http-TVY52JJZ.js → http-M63AQ3XH.js} +2 -2
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +3 -3
- package/dist/node.cjs +1 -1
- package/dist/node.cjs.map +1 -1
- package/dist/node.js +3 -3
- package/dist/{replay-SWPDIBQG.js → replay-E4YOU476.js} +3 -3
- package/dist/replayCli.js +3 -3
- package/dist/replayCli.js.map +1 -1
- package/dist/seedCli.js +2 -2
- package/package.json +1 -1
- /package/dist/{chunk-XADZNIOO.js.map → chunk-U6S5C7WE.js.map} +0 -0
- /package/dist/{http-LM5MZIOW.js.map → http-7Z2B7R4F.js.map} +0 -0
- /package/dist/{http-TVY52JJZ.js.map → http-M63AQ3XH.js.map} +0 -0
- /package/dist/{replay-SWPDIBQG.js.map → replay-E4YOU476.js.map} +0 -0
package/cloudReplay.py
CHANGED
|
@@ -11,6 +11,7 @@ import signal
|
|
|
11
11
|
import subprocess
|
|
12
12
|
import sys
|
|
13
13
|
import tempfile
|
|
14
|
+
import threading
|
|
14
15
|
import time
|
|
15
16
|
import uuid
|
|
16
17
|
from pathlib import Path
|
|
@@ -20,6 +21,20 @@ CONFIG = ".bitfab/cloud.json"
|
|
|
20
21
|
PREFIX = "bitfab-replay/"
|
|
21
22
|
DEFAULT_SECRET_PREFIX = "BITFAB_CLOUD_"
|
|
22
23
|
API_VERSION = "2026-03-10"
|
|
24
|
+
# The SDK finds runs by this title and feeds them through these inputs. Never change them.
|
|
25
|
+
RUN_NAME = "Bitfab replay ${{ inputs.execution_id }}"
|
|
26
|
+
RUNNER_ENV = {
|
|
27
|
+
"BITFAB_CLOUD_REQUEST": "${{ inputs.request }}",
|
|
28
|
+
"BITFAB_EXECUTION_ID": "${{ inputs.execution_id }}",
|
|
29
|
+
"BITFAB_COMMIT_SHA": "${{ github.sha }}",
|
|
30
|
+
}
|
|
31
|
+
# The runner reports its result as a job annotation with this title, so the workflow
|
|
32
|
+
# needs no upload step. Older workflows still upload RESULT_FILE as an artifact.
|
|
33
|
+
RESULT_TITLE = "Bitfab replay result"
|
|
34
|
+
RESULT_FILE = "bitfab-cloud-result.json"
|
|
35
|
+
# Steps older setups generated that the SDK now owns.
|
|
36
|
+
OLD_UPLOAD_STEP = "Save replay identity"
|
|
37
|
+
OLD_JOB_TIMEOUT = 35
|
|
23
38
|
ITEM_ERROR_FIELDS = (
|
|
24
39
|
"error",
|
|
25
40
|
"traceError",
|
|
@@ -30,13 +45,61 @@ ITEM_ERROR_FIELDS = (
|
|
|
30
45
|
UUID = re.compile(r"^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$")
|
|
31
46
|
SHA = re.compile(r"^[0-9a-f]{40}$")
|
|
32
47
|
PIPELINE = re.compile(r"^[\w.][\w.-]*$")
|
|
48
|
+
# Every SDK's replay prints this to stderr as soon as the server creates the experiment.
|
|
49
|
+
EXPERIMENT_LINE = re.compile(rb"^\[replay\] Experiment ([0-9a-f-]{36}):")
|
|
50
|
+
ITEM_ID_FIELDS = ("originalTraceId", "original_trace_id", "traceId", "trace_id")
|
|
51
|
+
ITEM_ERROR_LINES = 20
|
|
52
|
+
ITEM_ERROR_LENGTH = 500
|
|
53
|
+
OUTPUT_TAIL_LENGTH = 4000
|
|
54
|
+
ENV_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
55
|
+
CLOUD_VALUE_FLAGS = (
|
|
56
|
+
"--registry",
|
|
57
|
+
"--trace-ids",
|
|
58
|
+
"--max-concurrency",
|
|
59
|
+
"--cloud-status",
|
|
60
|
+
"--cloud-watch",
|
|
61
|
+
"--cloud-cancel",
|
|
62
|
+
"--cloud-cleanup",
|
|
63
|
+
"--cloud-request-id",
|
|
64
|
+
"--cloud-include",
|
|
65
|
+
"--cloud-timeout",
|
|
66
|
+
)
|
|
67
|
+
CLOUD_SWITCHES = (
|
|
68
|
+
"--cloud",
|
|
69
|
+
"--cloud-dry-run",
|
|
70
|
+
"--cloud-detach",
|
|
71
|
+
"--cloud-check",
|
|
72
|
+
"--fail-on-error",
|
|
73
|
+
"-h",
|
|
74
|
+
"--help",
|
|
75
|
+
)
|
|
76
|
+
RUNNER_OWNED_FLAGS = {
|
|
77
|
+
"--dry-run": "use --cloud-dry-run to review the snapshot or --cloud-check to resolve traces on the runner",
|
|
78
|
+
"--seed": "seed locally",
|
|
79
|
+
"--cases": "seed locally",
|
|
80
|
+
"--from-trace": "seed locally",
|
|
81
|
+
"--run": "seed locally",
|
|
82
|
+
}
|
|
83
|
+
PATH_FLAGS = ("--params", "--code-change")
|
|
84
|
+
SELECTION_FLAGS = ("--dataset-ids", "--dataset-id", "--resume")
|
|
33
85
|
HELP = """GitHub cloud replay (requires git, gh login, and Python 3.10+).
|
|
34
86
|
--cloud PIPELINE --trace-ids UUID[,UUID] [--registry PATH]
|
|
35
87
|
[--max-concurrency 1..32] [--cloud-request-id UUID]
|
|
36
88
|
[--cloud-include PATH ...] [--cloud-dry-run] [--cloud-detach]
|
|
89
|
+
[--cloud-timeout MINUTES] [--cloud-check] [--fail-on-error] [REPLAY OPTIONS]
|
|
90
|
+
Every other replay option after the pipeline, such as --name, --dataset-ids,
|
|
91
|
+
--attempts, --mock, or --resume, is passed to the replay on the runner as
|
|
92
|
+
given. --params and --code-change files must be in the snapshot.
|
|
93
|
+
--registry comes from .bitfab/cloud.json; --dry-run and seeding stay local.
|
|
94
|
+
Select traces with --trace-ids (1..100 UUIDs), --dataset-ids, or --resume.
|
|
95
|
+
--fail-on-error exits 1 locally when any replayed item errored.
|
|
96
|
+
--cloud-check runs on GitHub without replaying anything: it checks that every
|
|
97
|
+
configured secret has a value, runs cloud.json's checkCommand when set, and
|
|
98
|
+
resolves the traces with the replay's --dry-run to load the registry.
|
|
37
99
|
--cloud-status UUID | --cloud-watch UUID | --cloud-cancel UUID
|
|
38
100
|
--cloud-cleanup UUID
|
|
39
|
-
--cloud-init --config SPEC
|
|
101
|
+
--cloud-init [--config SPEC] (creates a setup, or brings an existing one up to date)
|
|
102
|
+
--cloud-secrets --env-file FILE [NAME ...]
|
|
40
103
|
PIPELINE may be any pipeline in the registry, unless .bitfab/cloud.json names one.
|
|
41
104
|
Snapshot tracked working files without changing HEAD, the index, or local files.
|
|
42
105
|
New files require explicit --cloud-include. Credentials and ignored files are refused.
|
|
@@ -45,6 +108,12 @@ continue on GitHub; watch/status/cleanup can recover them using the printed UUID
|
|
|
45
108
|
"""
|
|
46
109
|
|
|
47
110
|
|
|
111
|
+
class CommandError(RuntimeError):
|
|
112
|
+
def __init__(self, message, http_status=None):
|
|
113
|
+
super().__init__(message)
|
|
114
|
+
self.http_status = http_status
|
|
115
|
+
|
|
116
|
+
|
|
48
117
|
def command(args, *, cwd=None, env=None, timeout=60, input=None):
|
|
49
118
|
result = subprocess.run(
|
|
50
119
|
args,
|
|
@@ -58,8 +127,13 @@ def command(args, *, cwd=None, env=None, timeout=60, input=None):
|
|
|
58
127
|
)
|
|
59
128
|
if result.returncode:
|
|
60
129
|
# Child errors can contain credential-bearing URLs or application output.
|
|
61
|
-
|
|
62
|
-
|
|
130
|
+
status = re.search(r"\bHTTP (\d{3})\b", result.stderr or "")
|
|
131
|
+
http_status = int(status[1]) if status else None
|
|
132
|
+
raise CommandError(
|
|
133
|
+
f"{args[0]} {args[1]} failed (exit {result.returncode}"
|
|
134
|
+
+ (f", HTTP {http_status}" if http_status else "")
|
|
135
|
+
+ "); check authentication and permissions",
|
|
136
|
+
http_status,
|
|
63
137
|
)
|
|
64
138
|
return result.stdout.strip()
|
|
65
139
|
|
|
@@ -194,10 +268,10 @@ def configure_secrets(argv):
|
|
|
194
268
|
args = parser.parse_args(argv)
|
|
195
269
|
root = root_directory()
|
|
196
270
|
config = configuration(root)
|
|
197
|
-
|
|
198
|
-
names = args.names or
|
|
199
|
-
if not all(
|
|
200
|
-
raise ValueError("Secret names must be
|
|
271
|
+
targets = secret_targets(config)
|
|
272
|
+
names = args.names or list(targets) or ["BITFAB_API_KEY"]
|
|
273
|
+
if not all(ENV_NAME.fullmatch(name) for name in names):
|
|
274
|
+
raise ValueError("Secret names must be environment variable names")
|
|
201
275
|
values = {}
|
|
202
276
|
for path in args.env_file:
|
|
203
277
|
for key, value in parse_environment_file(
|
|
@@ -209,14 +283,15 @@ def configure_secrets(argv):
|
|
|
209
283
|
missing = []
|
|
210
284
|
empty = []
|
|
211
285
|
for name in names:
|
|
212
|
-
|
|
286
|
+
target = targets.get(name, config.get("secretPrefix", "") + name)
|
|
287
|
+
renamed = "secret" in config.get("env", {}).get(name, {})
|
|
288
|
+
value = values.get(target, values.get(name)) if renamed else values.get(name)
|
|
213
289
|
if value is None:
|
|
214
290
|
missing.append(name)
|
|
215
291
|
continue
|
|
216
292
|
if not value:
|
|
217
293
|
empty.append(name)
|
|
218
294
|
continue
|
|
219
|
-
target = prefix + name
|
|
220
295
|
if not args.dry_run:
|
|
221
296
|
command(
|
|
222
297
|
[
|
|
@@ -242,6 +317,48 @@ def configure_secrets(argv):
|
|
|
242
317
|
}
|
|
243
318
|
|
|
244
319
|
|
|
320
|
+
def secret_targets(config):
|
|
321
|
+
prefix = config.get("secretPrefix", "")
|
|
322
|
+
targets = {name: prefix + name for name in config.get("secrets", [])}
|
|
323
|
+
for name, source in config.get("env", {}).items():
|
|
324
|
+
if "secret" in source:
|
|
325
|
+
targets[name] = source["secret"]
|
|
326
|
+
return targets
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def runner_env(config):
|
|
330
|
+
env = {
|
|
331
|
+
name: "${{ secrets." + target + " }}"
|
|
332
|
+
for name, target in secret_targets(config).items()
|
|
333
|
+
}
|
|
334
|
+
for name, source in config.get("env", {}).items():
|
|
335
|
+
if "variable" in source:
|
|
336
|
+
env[name] = "${{ vars." + source["variable"] + " }}"
|
|
337
|
+
return env
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def validate_env(mapping, secrets):
|
|
341
|
+
if not isinstance(mapping, dict):
|
|
342
|
+
raise ValueError(
|
|
343
|
+
'env must map environment variable names to {"secret": NAME} or {"variable": NAME}'
|
|
344
|
+
)
|
|
345
|
+
for name, source in mapping.items():
|
|
346
|
+
if not ENV_NAME.fullmatch(name) or name in RUNNER_ENV:
|
|
347
|
+
raise ValueError(f"env cannot set {name}")
|
|
348
|
+
if name in secrets:
|
|
349
|
+
raise ValueError(f"{name} is in both secrets and env; list it once")
|
|
350
|
+
if (
|
|
351
|
+
not isinstance(source, dict)
|
|
352
|
+
or len(source) != 1
|
|
353
|
+
or next(iter(source)) not in ("secret", "variable")
|
|
354
|
+
or not isinstance(next(iter(source.values())), str)
|
|
355
|
+
or not ENV_NAME.fullmatch(next(iter(source.values())))
|
|
356
|
+
):
|
|
357
|
+
raise ValueError(
|
|
358
|
+
f'env.{name} must be {{"secret": "GITHUB_SECRET_NAME"}} or {{"variable": "GITHUB_VARIABLE_NAME"}}'
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
|
|
245
362
|
def validate_config(config):
|
|
246
363
|
if config.get("version") != 1 or config.get("provider") != "github":
|
|
247
364
|
raise ValueError("Run bitfab:setup cloud to configure the GitHub provider")
|
|
@@ -266,6 +383,8 @@ def validate_config(config):
|
|
|
266
383
|
raise ValueError(
|
|
267
384
|
"The runner command must execute locally, not recursively dispatch"
|
|
268
385
|
)
|
|
386
|
+
if "cliCommand" in config:
|
|
387
|
+
validate_cli_command(config["cliCommand"])
|
|
269
388
|
if config.get("pushTriggersReviewed") is not True:
|
|
270
389
|
raise ValueError(
|
|
271
390
|
"Setup must review push-triggered CI/deployments and set pushTriggersReviewed=true"
|
|
@@ -283,6 +402,30 @@ def validate_config(config):
|
|
|
283
402
|
for name in names
|
|
284
403
|
):
|
|
285
404
|
raise ValueError("secrets must be uppercase environment variable names")
|
|
405
|
+
validate_env(config.get("env", {}), names)
|
|
406
|
+
if "checkCommand" in config:
|
|
407
|
+
check = config["checkCommand"]
|
|
408
|
+
if (
|
|
409
|
+
not isinstance(check, list)
|
|
410
|
+
or not check
|
|
411
|
+
or not all(isinstance(v, str) and v and "\x00" not in v for v in check)
|
|
412
|
+
or any(v.startswith("--cloud") for v in check)
|
|
413
|
+
):
|
|
414
|
+
raise ValueError(
|
|
415
|
+
"checkCommand must be a nonempty JSON argument array that runs locally"
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def validate_cli_command(cli_command):
|
|
420
|
+
if (
|
|
421
|
+
not isinstance(cli_command, list)
|
|
422
|
+
or not cli_command
|
|
423
|
+
or not all(isinstance(v, str) and v and "\x00" not in v for v in cli_command)
|
|
424
|
+
or any(v.startswith("--cloud") for v in cli_command)
|
|
425
|
+
):
|
|
426
|
+
raise ValueError(
|
|
427
|
+
"cliCommand must be the argument array that starts the SDK's bitfab-replay command from workingDirectory"
|
|
428
|
+
)
|
|
286
429
|
|
|
287
430
|
|
|
288
431
|
def check_pipeline(config, pipeline):
|
|
@@ -322,7 +465,53 @@ def api(repo, suffix, *, method="GET", payload=None):
|
|
|
322
465
|
return json.loads(result) if result else None
|
|
323
466
|
|
|
324
467
|
|
|
468
|
+
def split_replay_options(argv):
|
|
469
|
+
cloud, options = [], []
|
|
470
|
+
index = 0
|
|
471
|
+
while index < len(argv):
|
|
472
|
+
token = argv[index]
|
|
473
|
+
name = token.split("=", 1)[0]
|
|
474
|
+
if name in CLOUD_VALUE_FLAGS:
|
|
475
|
+
step = 1 if "=" in token else 2
|
|
476
|
+
cloud += argv[index : index + step]
|
|
477
|
+
index += step
|
|
478
|
+
elif name in CLOUD_SWITCHES:
|
|
479
|
+
cloud.append(token)
|
|
480
|
+
index += 1
|
|
481
|
+
elif token.startswith("-"):
|
|
482
|
+
if name in RUNNER_OWNED_FLAGS:
|
|
483
|
+
raise ValueError(
|
|
484
|
+
f"{name} is not sent to the runner; {RUNNER_OWNED_FLAGS[name]}"
|
|
485
|
+
)
|
|
486
|
+
options += token.split("=", 1) if token.startswith("--") else [token]
|
|
487
|
+
index += 1
|
|
488
|
+
following = argv[index] if index < len(argv) else None
|
|
489
|
+
if (
|
|
490
|
+
"=" not in token
|
|
491
|
+
and following is not None
|
|
492
|
+
and not following.startswith("-")
|
|
493
|
+
):
|
|
494
|
+
options.append(following)
|
|
495
|
+
index += 1
|
|
496
|
+
elif options:
|
|
497
|
+
raise ValueError(
|
|
498
|
+
"Put the pipeline right after --cloud, before replay options"
|
|
499
|
+
)
|
|
500
|
+
else:
|
|
501
|
+
cloud.append(token)
|
|
502
|
+
index += 1
|
|
503
|
+
for value in options:
|
|
504
|
+
if not value or "\x00" in value or "\n" in value or len(value) > 1000:
|
|
505
|
+
raise ValueError(
|
|
506
|
+
"Replay options must be single-line values of at most 1000 characters"
|
|
507
|
+
)
|
|
508
|
+
if sum(len(value) for value in options) > 16000:
|
|
509
|
+
raise ValueError("Replay options exceed 16000 characters")
|
|
510
|
+
return cloud, options
|
|
511
|
+
|
|
512
|
+
|
|
325
513
|
def parse(argv):
|
|
514
|
+
argv, options = split_replay_options(argv)
|
|
326
515
|
flags = [value.split("=", 1)[0] for value in argv if value.startswith("--")]
|
|
327
516
|
if any(flags.count(flag) > 1 for flag in flags if flag != "--cloud-include"):
|
|
328
517
|
raise ValueError("Duplicate cloud option")
|
|
@@ -342,32 +531,68 @@ def parse(argv):
|
|
|
342
531
|
parser.add_argument("--cloud-include", action="append", default=[])
|
|
343
532
|
parser.add_argument("--cloud-dry-run", action="store_true")
|
|
344
533
|
parser.add_argument("--cloud-detach", action="store_true")
|
|
534
|
+
parser.add_argument("--cloud-timeout", type=int)
|
|
535
|
+
parser.add_argument("--cloud-check", action="store_true")
|
|
536
|
+
parser.add_argument("--fail-on-error", action="store_true")
|
|
345
537
|
args = parser.parse_args(argv)
|
|
538
|
+
args.options = options
|
|
346
539
|
operations = [
|
|
347
540
|
name
|
|
348
541
|
for name in ("status", "watch", "cancel", "cleanup")
|
|
349
542
|
if getattr(args, "cloud_" + name)
|
|
350
543
|
]
|
|
351
544
|
if operations:
|
|
352
|
-
if len(operations) != 1 or len(argv) != 2:
|
|
545
|
+
if len(operations) != 1 or len(argv) != 2 or options:
|
|
353
546
|
raise ValueError("Cloud lifecycle commands take only their execution UUID")
|
|
354
547
|
operation = operations[0]
|
|
355
548
|
execution_id = getattr(args, "cloud_" + operation)
|
|
356
549
|
else:
|
|
357
|
-
if not args.cloud or not args.pipeline
|
|
550
|
+
if not args.cloud or not args.pipeline:
|
|
358
551
|
raise ValueError(HELP)
|
|
552
|
+
if not args.trace_ids and not any(flag in options for flag in SELECTION_FLAGS):
|
|
553
|
+
raise ValueError(
|
|
554
|
+
"Select traces with --trace-ids, --dataset-ids, or --resume"
|
|
555
|
+
)
|
|
359
556
|
operation = "submit"
|
|
360
557
|
execution_id = args.cloud_request_id or str(uuid.uuid4())
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
558
|
+
if args.trace_ids is not None:
|
|
559
|
+
traces = args.trace_ids.split(",")
|
|
560
|
+
if not 1 <= len(traces) <= 100 or not all(
|
|
561
|
+
UUID.fullmatch(t) for t in traces
|
|
562
|
+
):
|
|
563
|
+
raise ValueError("Supply 1..100 explicit trace UUIDs")
|
|
364
564
|
if not 1 <= args.max_concurrency <= 32:
|
|
365
565
|
raise ValueError("--max-concurrency must be 1..32")
|
|
566
|
+
# Self-hosted runners allow jobs of up to 5 days; GitHub-hosted ones stop at 6 hours.
|
|
567
|
+
if args.cloud_timeout is not None and not 1 <= args.cloud_timeout <= 7200:
|
|
568
|
+
raise ValueError("--cloud-timeout must be 1..7200 minutes")
|
|
366
569
|
if not UUID.fullmatch(execution_id):
|
|
367
570
|
raise ValueError("Execution ID must be a UUID")
|
|
368
571
|
return args, operation, execution_id
|
|
369
572
|
|
|
370
573
|
|
|
574
|
+
def option_paths(options):
|
|
575
|
+
return [value for flag, value in zip(options, options[1:]) if flag in PATH_FLAGS]
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def map_option_paths(options, convert):
|
|
579
|
+
mapped = list(options)
|
|
580
|
+
for index, flag in enumerate(options[:-1]):
|
|
581
|
+
if flag in PATH_FLAGS:
|
|
582
|
+
mapped[index + 1] = convert(options[index + 1])
|
|
583
|
+
return mapped
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def repository_path(root, value):
|
|
587
|
+
try:
|
|
588
|
+
path = Path(value).resolve().relative_to(root).as_posix()
|
|
589
|
+
except ValueError as error:
|
|
590
|
+
raise ValueError(f"{value} must be inside the repository") from error
|
|
591
|
+
if sensitive(path):
|
|
592
|
+
raise ValueError(f"Refusing credential-like file: {value}")
|
|
593
|
+
return path
|
|
594
|
+
|
|
595
|
+
|
|
371
596
|
def sensitive(path):
|
|
372
597
|
parts = Path(path).parts
|
|
373
598
|
name = Path(path).name.lower()
|
|
@@ -391,7 +616,7 @@ def sensitive(path):
|
|
|
391
616
|
)
|
|
392
617
|
|
|
393
618
|
|
|
394
|
-
def snapshot(root, config, args, execution_id):
|
|
619
|
+
def snapshot(root, config, args, execution_id, files=()):
|
|
395
620
|
if git(root, "ls-files", "-u"):
|
|
396
621
|
raise ValueError("Resolve merge conflicts before snapshotting")
|
|
397
622
|
head = git(root, "rev-parse", "HEAD")
|
|
@@ -428,7 +653,7 @@ def snapshot(root, config, args, execution_id):
|
|
|
428
653
|
raise ValueError(
|
|
429
654
|
f"Refusing credential-like tracked file in snapshot: {path}"
|
|
430
655
|
)
|
|
431
|
-
required = [CONFIG, f".github/workflows/{config['workflow']}"]
|
|
656
|
+
required = [CONFIG, f".github/workflows/{config['workflow']}", *files]
|
|
432
657
|
if config.get("registry") is not None:
|
|
433
658
|
required.append(config["registry"])
|
|
434
659
|
tracked = set(
|
|
@@ -547,41 +772,94 @@ def status(record, *, fetch_result=True):
|
|
|
547
772
|
if (
|
|
548
773
|
fetch_result
|
|
549
774
|
and record["state"] == "completed"
|
|
550
|
-
and record["conclusion"] == "success"
|
|
551
775
|
and not record.get("testRunId")
|
|
776
|
+
and not record.get("check")
|
|
777
|
+
and not record.get("resultChecked")
|
|
552
778
|
):
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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", ""))
|
|
779
|
+
success = record["conclusion"] == "success"
|
|
780
|
+
try:
|
|
781
|
+
result = read_result(record)
|
|
782
|
+
except RuntimeError:
|
|
783
|
+
# A run that failed before its replay started reports no result.
|
|
784
|
+
if success:
|
|
785
|
+
raise
|
|
786
|
+
record["resultChecked"] = True
|
|
787
|
+
return record
|
|
788
|
+
if (
|
|
789
|
+
result.get("executionId") != record["id"]
|
|
790
|
+
or result.get("commitSha") != record["sha"]
|
|
791
|
+
):
|
|
792
|
+
raise ValueError("Replay result does not match this execution")
|
|
793
|
+
if record["request"].get("check"):
|
|
794
|
+
if result.get("check") != "passed" or not isinstance(
|
|
795
|
+
result.get("resolved"), int
|
|
576
796
|
):
|
|
577
|
-
raise ValueError("
|
|
578
|
-
|
|
579
|
-
record["
|
|
580
|
-
|
|
581
|
-
|
|
797
|
+
raise ValueError("Cloud check result does not match this execution")
|
|
798
|
+
record["check"] = "passed"
|
|
799
|
+
record["resolved"] = result["resolved"]
|
|
800
|
+
return record
|
|
801
|
+
if not UUID.fullmatch(result.get("testRunId", "")):
|
|
802
|
+
raise ValueError("Replay result does not match this execution")
|
|
803
|
+
counts = replay_counts(result)
|
|
804
|
+
record["testRunId"] = result["testRunId"]
|
|
805
|
+
if isinstance(result.get("stoppedEarly"), str):
|
|
806
|
+
record["stoppedEarly"] = result["stoppedEarly"]
|
|
807
|
+
if counts is not None:
|
|
808
|
+
record["replayed"], record["errored"] = counts
|
|
582
809
|
return record
|
|
583
810
|
|
|
584
811
|
|
|
812
|
+
def read_result(record):
|
|
813
|
+
try:
|
|
814
|
+
result = read_result_annotation(record)
|
|
815
|
+
except RuntimeError:
|
|
816
|
+
# Older workflows still upload the result, so a failed Checks API read
|
|
817
|
+
# must not hide it.
|
|
818
|
+
result = None
|
|
819
|
+
return result or read_result_artifact(record)
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def read_result_annotation(record):
|
|
823
|
+
repo = record["repository"]
|
|
824
|
+
jobs = api(repo, f"actions/runs/{record['runId']}/jobs?per_page=100") or {}
|
|
825
|
+
for job in jobs.get("jobs", []):
|
|
826
|
+
check_run = str(job.get("check_run_url", "")).rsplit("/", 1)[-1]
|
|
827
|
+
if not check_run.isdigit():
|
|
828
|
+
continue
|
|
829
|
+
for note in api(repo, f"check-runs/{check_run}/annotations?per_page=100") or []:
|
|
830
|
+
if (
|
|
831
|
+
note.get("title") == RESULT_TITLE
|
|
832
|
+
and len(note.get("message", "")) <= 4096
|
|
833
|
+
):
|
|
834
|
+
try:
|
|
835
|
+
return json.loads(base64.b64decode(note["message"], validate=True))
|
|
836
|
+
except ValueError as error:
|
|
837
|
+
raise ValueError("Invalid replay result annotation") from error
|
|
838
|
+
return None
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def read_result_artifact(record):
|
|
842
|
+
with tempfile.TemporaryDirectory(prefix="bitfab-cloud-result-") as directory:
|
|
843
|
+
command(
|
|
844
|
+
[
|
|
845
|
+
"gh",
|
|
846
|
+
"run",
|
|
847
|
+
"download",
|
|
848
|
+
str(record["runId"]),
|
|
849
|
+
"--repo",
|
|
850
|
+
record["repository"],
|
|
851
|
+
"--name",
|
|
852
|
+
"bitfab-replay-" + record["id"],
|
|
853
|
+
"--dir",
|
|
854
|
+
directory,
|
|
855
|
+
]
|
|
856
|
+
)
|
|
857
|
+
artifact = Path(directory) / RESULT_FILE
|
|
858
|
+
if artifact.is_symlink() or artifact.stat().st_size > 4096:
|
|
859
|
+
raise ValueError("Invalid replay result artifact")
|
|
860
|
+
return json.loads(artifact.read_text())
|
|
861
|
+
|
|
862
|
+
|
|
585
863
|
def cleanup(root, record):
|
|
586
864
|
if record.get("cleaned"):
|
|
587
865
|
return
|
|
@@ -608,16 +886,35 @@ def cleanup(root, record):
|
|
|
608
886
|
record["cleaned"] = True
|
|
609
887
|
|
|
610
888
|
|
|
889
|
+
def http_status(error):
|
|
890
|
+
status = getattr(error, "http_status", None)
|
|
891
|
+
return f" (HTTP {status})" if status else ""
|
|
892
|
+
|
|
893
|
+
|
|
611
894
|
def preflight(repo, config):
|
|
612
|
-
|
|
613
|
-
|
|
895
|
+
try:
|
|
896
|
+
command(["gh", "auth", "status", "--hostname", "github.com"])
|
|
897
|
+
except RuntimeError as error:
|
|
898
|
+
raise ValueError(
|
|
899
|
+
"gh is not logged in to github.com; run gh auth login"
|
|
900
|
+
) from error
|
|
901
|
+
try:
|
|
902
|
+
api(repo, "")
|
|
903
|
+
except RuntimeError as error:
|
|
904
|
+
raise ValueError(
|
|
905
|
+
f"The gh account cannot read {repo}{http_status(error)}; log in with an account that has write access to it"
|
|
906
|
+
) from error
|
|
907
|
+
path = f".github/workflows/{config['workflow']}"
|
|
908
|
+
try:
|
|
909
|
+
workflow = api(repo, f"actions/workflows/{config['workflow']}")
|
|
910
|
+
except RuntimeError as error:
|
|
911
|
+
raise ValueError(
|
|
912
|
+
f"GitHub Actions has not registered {path}{http_status(error)}; merging it to the default branch once registers it, and after that each replay runs the copy in its own snapshot"
|
|
913
|
+
) from error
|
|
614
914
|
if workflow.get("state") != "active":
|
|
615
|
-
raise ValueError(
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
f"contents/.github/workflows/{config['workflow']}?"
|
|
619
|
-
+ urlencode({"ref": info["default_branch"]}),
|
|
620
|
-
)
|
|
915
|
+
raise ValueError(
|
|
916
|
+
f"{path} is {workflow.get('state')}; enable it in the repository's Actions tab"
|
|
917
|
+
)
|
|
621
918
|
|
|
622
919
|
|
|
623
920
|
def run_cli(argv):
|
|
@@ -637,17 +934,27 @@ def run_cli(argv):
|
|
|
637
934
|
registry = Path(args.registry).resolve().relative_to(root).as_posix()
|
|
638
935
|
if registry != config.get("registry"):
|
|
639
936
|
raise ValueError("Registry does not match .bitfab/cloud.json")
|
|
937
|
+
options = map_option_paths(
|
|
938
|
+
args.options, lambda value: repository_path(root, value)
|
|
939
|
+
)
|
|
640
940
|
request = {
|
|
641
941
|
"id": execution_id,
|
|
642
942
|
"pipeline": args.pipeline,
|
|
643
|
-
"traceIds": args.trace_ids.split(","),
|
|
644
943
|
"maxConcurrency": args.max_concurrency,
|
|
645
944
|
}
|
|
945
|
+
if args.trace_ids is not None:
|
|
946
|
+
request["traceIds"] = args.trace_ids.split(",")
|
|
947
|
+
if options:
|
|
948
|
+
request["options"] = options
|
|
949
|
+
if args.cloud_check:
|
|
950
|
+
request["check"] = True
|
|
951
|
+
if args.cloud_timeout is not None:
|
|
952
|
+
request["timeoutMinutes"] = args.cloud_timeout
|
|
646
953
|
if args.cloud_dry_run:
|
|
647
954
|
return {
|
|
648
955
|
"dryRun": True,
|
|
649
956
|
"repository": repo,
|
|
650
|
-
**snapshot(root, config, args, execution_id),
|
|
957
|
+
**snapshot(root, config, args, execution_id, option_paths(options)),
|
|
651
958
|
}
|
|
652
959
|
if path.exists():
|
|
653
960
|
record = json.loads(path.read_text())
|
|
@@ -665,9 +972,10 @@ def run_cli(argv):
|
|
|
665
972
|
"Submission stopped before dispatch. Use --cloud-cleanup, then submit a new execution UUID"
|
|
666
973
|
)
|
|
667
974
|
else:
|
|
668
|
-
command(["gh", "auth", "status", "--hostname", "github.com"])
|
|
669
975
|
preflight(repo, config)
|
|
670
|
-
source = snapshot(
|
|
976
|
+
source = snapshot(
|
|
977
|
+
root, config, args, execution_id, option_paths(options)
|
|
978
|
+
)
|
|
671
979
|
record = {
|
|
672
980
|
"id": execution_id,
|
|
673
981
|
"repository": repo,
|
|
@@ -677,6 +985,8 @@ def run_cli(argv):
|
|
|
677
985
|
"state": "prepared",
|
|
678
986
|
**source,
|
|
679
987
|
}
|
|
988
|
+
if args.fail_on_error:
|
|
989
|
+
record["failOnError"] = True
|
|
680
990
|
save(path, record)
|
|
681
991
|
print(
|
|
682
992
|
f"Cloud execution {execution_id}. Recover with --cloud-status {execution_id}",
|
|
@@ -684,26 +994,39 @@ def run_cli(argv):
|
|
|
684
994
|
flush=True,
|
|
685
995
|
)
|
|
686
996
|
ref = "refs/heads/" + record["branch"]
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
997
|
+
try:
|
|
998
|
+
# Empty lease asserts that the temporary remote branch does not exist.
|
|
999
|
+
git(
|
|
1000
|
+
root,
|
|
1001
|
+
"push",
|
|
1002
|
+
f"--force-with-lease={ref}:",
|
|
1003
|
+
"origin",
|
|
1004
|
+
f"{record['sha']}:{ref}",
|
|
1005
|
+
)
|
|
1006
|
+
except RuntimeError as error:
|
|
1007
|
+
raise ValueError(
|
|
1008
|
+
f"Pushing the snapshot branch {record['branch']} to origin failed; check that git can push to {repo}"
|
|
1009
|
+
) from error
|
|
695
1010
|
record["state"] = "dispatch_unknown"
|
|
696
1011
|
save(path, record)
|
|
697
1012
|
encoded = base64.b64encode(json.dumps(request).encode()).decode()
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
1013
|
+
try:
|
|
1014
|
+
response = api(
|
|
1015
|
+
repo,
|
|
1016
|
+
f"actions/workflows/{config['workflow']}/dispatches",
|
|
1017
|
+
method="POST",
|
|
1018
|
+
payload={
|
|
1019
|
+
"ref": record["branch"],
|
|
1020
|
+
"inputs": {
|
|
1021
|
+
"execution_id": execution_id,
|
|
1022
|
+
"request": encoded,
|
|
1023
|
+
},
|
|
1024
|
+
},
|
|
1025
|
+
)
|
|
1026
|
+
except RuntimeError as error:
|
|
1027
|
+
raise ValueError(
|
|
1028
|
+
f"Dispatching {config['workflow']} on {record['branch']} failed{http_status(error)}; the gh account needs write access to Actions, and the registered workflow must accept workflow_dispatch. Check the Actions tab, then resume with --cloud-status {execution_id}"
|
|
1029
|
+
) from error
|
|
707
1030
|
if isinstance(response, dict) and response.get("workflow_run_id"):
|
|
708
1031
|
record["runId"] = response["workflow_run_id"]
|
|
709
1032
|
record["url"] = response.get("html_url")
|
|
@@ -729,17 +1052,24 @@ def run_cli(argv):
|
|
|
729
1052
|
save(path, record)
|
|
730
1053
|
return record
|
|
731
1054
|
if operation == "watch" or (operation == "submit" and not args.cloud_detach):
|
|
732
|
-
|
|
733
|
-
|
|
1055
|
+
# Once GitHub shows the run, its job timeout bounds the wait and Ctrl-C detaches.
|
|
1056
|
+
# A dispatch GitHub never shows as a run would otherwise be waited on forever.
|
|
1057
|
+
started = time.monotonic()
|
|
1058
|
+
while record["state"] != "completed":
|
|
734
1059
|
status(record)
|
|
735
1060
|
save(path, record)
|
|
1061
|
+
if (
|
|
1062
|
+
record["state"] == "dispatch_unknown"
|
|
1063
|
+
and time.monotonic() - started >= 10 * 60
|
|
1064
|
+
):
|
|
1065
|
+
raise ValueError(
|
|
1066
|
+
"GitHub has shown no run for this dispatch after 10 minutes. Check Actions, then resume with --cloud-watch "
|
|
1067
|
+
+ execution_id
|
|
1068
|
+
+ " or remove the snapshot branch with --cloud-cleanup "
|
|
1069
|
+
+ execution_id
|
|
1070
|
+
)
|
|
736
1071
|
if record["state"] != "completed":
|
|
737
1072
|
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
1073
|
if record["state"] == "completed" or (
|
|
744
1074
|
operation == "cleanup" and record["state"] == "prepared"
|
|
745
1075
|
):
|
|
@@ -763,61 +1093,279 @@ def execute():
|
|
|
763
1093
|
check_pipeline(config, request["pipeline"])
|
|
764
1094
|
if request["id"] != os.environ["BITFAB_EXECUTION_ID"]:
|
|
765
1095
|
raise ValueError("Replay request does not match the configured execution")
|
|
766
|
-
|
|
1096
|
+
timeout = request.get("timeoutMinutes")
|
|
1097
|
+
traces = (
|
|
1098
|
+
["--trace-ids", ",".join(request["traceIds"])] if "traceIds" in request else []
|
|
1099
|
+
)
|
|
1100
|
+
options = request.get("options", [])
|
|
1101
|
+
parsed, _, _ = parse(
|
|
767
1102
|
[
|
|
768
1103
|
"--cloud",
|
|
769
1104
|
request["pipeline"],
|
|
770
|
-
|
|
771
|
-
",".join(request["traceIds"]),
|
|
1105
|
+
*traces,
|
|
772
1106
|
"--max-concurrency",
|
|
773
1107
|
str(request["maxConcurrency"]),
|
|
774
1108
|
"--cloud-request-id",
|
|
775
1109
|
request["id"],
|
|
1110
|
+
*([] if timeout is None else ["--cloud-timeout", str(timeout)]),
|
|
1111
|
+
*(["--cloud-check"] if request.get("check") else []),
|
|
1112
|
+
*options,
|
|
776
1113
|
]
|
|
777
1114
|
)
|
|
778
|
-
if
|
|
779
|
-
raise ValueError("
|
|
1115
|
+
if parsed.options != options:
|
|
1116
|
+
raise ValueError("Replay options were not in the form the SDK sends")
|
|
1117
|
+
check_secrets(config)
|
|
780
1118
|
args = [
|
|
781
1119
|
*config["command"],
|
|
782
1120
|
request["pipeline"],
|
|
783
|
-
|
|
784
|
-
",".join(request["traceIds"]),
|
|
1121
|
+
*traces,
|
|
785
1122
|
"--max-concurrency",
|
|
786
1123
|
str(request["maxConcurrency"]),
|
|
787
|
-
|
|
1124
|
+
*map_option_paths(options, lambda value: snapshot_file(root, value)),
|
|
1125
|
+
*(
|
|
1126
|
+
[]
|
|
1127
|
+
if "--code-change" in options or "--no-code-change" in options
|
|
1128
|
+
else ["--no-code-change"]
|
|
1129
|
+
),
|
|
788
1130
|
]
|
|
1131
|
+
if request.get("check"):
|
|
1132
|
+
summary = run_check(root, config, request, args)
|
|
1133
|
+
write_result(summary)
|
|
1134
|
+
return summary
|
|
1135
|
+
experiment = {}
|
|
1136
|
+
# GitHub cancels and times out a job with SIGINT then SIGTERM; turn SIGTERM into the
|
|
1137
|
+
# same interrupt so the replay is stopped cleanly and its experiment is still reported.
|
|
1138
|
+
previous = signal.signal(signal.SIGTERM, raise_interrupt)
|
|
1139
|
+
try:
|
|
1140
|
+
summary = run_replay(root, config, request, args, timeout, experiment)
|
|
1141
|
+
except BaseException as error:
|
|
1142
|
+
if UUID.fullmatch(experiment.get("id", "")):
|
|
1143
|
+
write_result(
|
|
1144
|
+
{
|
|
1145
|
+
"executionId": request["id"],
|
|
1146
|
+
"commitSha": os.environ["GITHUB_SHA"],
|
|
1147
|
+
"testRunId": experiment["id"],
|
|
1148
|
+
"stoppedEarly": (str(error) or type(error).__name__)[:300],
|
|
1149
|
+
}
|
|
1150
|
+
)
|
|
1151
|
+
raise
|
|
1152
|
+
finally:
|
|
1153
|
+
signal.signal(signal.SIGTERM, previous)
|
|
1154
|
+
write_result(summary)
|
|
1155
|
+
return summary
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
def snapshot_file(root, value):
|
|
1159
|
+
path = within(root, value)
|
|
1160
|
+
if sensitive(value) or not path.is_file():
|
|
1161
|
+
raise ValueError(f"{value} is not a file in the replay snapshot")
|
|
1162
|
+
return str(path)
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
def check_secrets(config):
|
|
1166
|
+
targets = {"BITFAB_API_KEY": "BITFAB_API_KEY", **secret_targets(config)}
|
|
1167
|
+
empty = [name for name in targets if not os.environ.get(name)]
|
|
1168
|
+
if empty:
|
|
1169
|
+
raise ValueError(
|
|
1170
|
+
"These replay environment variables are empty on the runner: "
|
|
1171
|
+
+ ", ".join(f"{name} (secret {targets[name]})" for name in empty)
|
|
1172
|
+
+ ". GitHub passes a secret that does not exist as an empty string. Create each one under Settings, Secrets and variables, Actions, in the repository or in the job's Environment"
|
|
1173
|
+
)
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
def run_check(root, config, request, args):
|
|
1177
|
+
directory = within(root, config["workingDirectory"])
|
|
1178
|
+
if config.get("checkCommand"):
|
|
1179
|
+
print(f"Running checkCommand: {shlex.join(config['checkCommand'])}", flush=True)
|
|
1180
|
+
code = subprocess.run(
|
|
1181
|
+
config["checkCommand"], cwd=directory, stdin=subprocess.DEVNULL, check=False
|
|
1182
|
+
).returncode
|
|
1183
|
+
if code:
|
|
1184
|
+
raise ValueError(f"checkCommand exited {code}; its output is above")
|
|
1185
|
+
result = run_command(root, config, [*args, "--dry-run"], None, {})
|
|
1186
|
+
items = result.get("items")
|
|
1187
|
+
if not isinstance(items, list):
|
|
1188
|
+
raise ValueError("The replay dry run did not return its resolved items")
|
|
1189
|
+
errors = item_errors(items)
|
|
1190
|
+
report_item_errors(errors)
|
|
1191
|
+
if errors:
|
|
1192
|
+
raise ValueError(
|
|
1193
|
+
f"{len(errors)} of {len(items)} traces failed to resolve; the errors are above"
|
|
1194
|
+
)
|
|
1195
|
+
return {
|
|
1196
|
+
"executionId": request["id"],
|
|
1197
|
+
"commitSha": os.environ["GITHUB_SHA"],
|
|
1198
|
+
"check": "passed",
|
|
1199
|
+
"resolved": len(items),
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
def item_errors(items):
|
|
1204
|
+
errors = []
|
|
1205
|
+
for item in items:
|
|
1206
|
+
if not item_errored(item):
|
|
1207
|
+
continue
|
|
1208
|
+
error = next(
|
|
1209
|
+
item[field] for field in ITEM_ERROR_FIELDS if item.get(field) is not None
|
|
1210
|
+
)
|
|
1211
|
+
if isinstance(error, dict):
|
|
1212
|
+
error = error.get("message") or json.dumps(error)
|
|
1213
|
+
trace = next(
|
|
1214
|
+
(
|
|
1215
|
+
item[field]
|
|
1216
|
+
for field in ITEM_ID_FIELDS
|
|
1217
|
+
if isinstance(item.get(field), str)
|
|
1218
|
+
),
|
|
1219
|
+
"unknown trace",
|
|
1220
|
+
)
|
|
1221
|
+
text = " ".join(str(error).split())
|
|
1222
|
+
if len(text) > ITEM_ERROR_LENGTH:
|
|
1223
|
+
text = text[:ITEM_ERROR_LENGTH] + "..."
|
|
1224
|
+
errors.append((trace, text))
|
|
1225
|
+
return errors
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
def report_item_errors(errors):
|
|
1229
|
+
if not errors:
|
|
1230
|
+
return
|
|
1231
|
+
shown = errors[:ITEM_ERROR_LINES]
|
|
1232
|
+
lines = [f"trace {trace}: {text}" for trace, text in shown]
|
|
1233
|
+
if len(errors) > len(shown):
|
|
1234
|
+
lines.append(f"and {len(errors) - len(shown)} more errored items")
|
|
1235
|
+
print("Errored items:", file=sys.stderr)
|
|
1236
|
+
for line in lines:
|
|
1237
|
+
print(" " + line, file=sys.stderr)
|
|
1238
|
+
sys.stderr.flush()
|
|
1239
|
+
summary = os.environ.get("GITHUB_STEP_SUMMARY")
|
|
1240
|
+
if summary:
|
|
1241
|
+
with Path(summary).open("a") as file:
|
|
1242
|
+
file.write(
|
|
1243
|
+
"\n#### Errored items\n\n"
|
|
1244
|
+
+ "".join(f"- `{line.replace('`', chr(39))}`\n" for line in lines)
|
|
1245
|
+
)
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
def raise_interrupt(signum, frame):
|
|
1249
|
+
raise KeyboardInterrupt
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
def forward_stderr(stream, experiment):
|
|
1253
|
+
for line in iter(stream.readline, b""):
|
|
1254
|
+
sys.stderr.buffer.write(line)
|
|
1255
|
+
sys.stderr.flush()
|
|
1256
|
+
if "id" not in experiment:
|
|
1257
|
+
match = EXPERIMENT_LINE.match(line)
|
|
1258
|
+
if match:
|
|
1259
|
+
experiment["id"] = match[1].decode()
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
def write_result(summary):
|
|
1263
|
+
# Base64, because GitHub masks every line of a multi-line secret in logs and
|
|
1264
|
+
# annotations, so a JSON credential alone turns each { and } into ***.
|
|
1265
|
+
message = base64.b64encode(json.dumps(summary).encode()).decode()
|
|
1266
|
+
print(f"::notice title={RESULT_TITLE}::{message}", flush=True)
|
|
1267
|
+
if "check" in summary:
|
|
1268
|
+
with Path(os.environ["GITHUB_STEP_SUMMARY"]).open("a") as file:
|
|
1269
|
+
file.write(
|
|
1270
|
+
f"### Bitfab cloud check\n\nPassed: every secret has a value and {summary['resolved']} traces resolved. Commit: `{summary['commitSha']}`\n"
|
|
1271
|
+
)
|
|
1272
|
+
return
|
|
1273
|
+
lines = [
|
|
1274
|
+
f"Test run: `{summary['testRunId']}`",
|
|
1275
|
+
f"Commit: `{summary['commitSha']}`",
|
|
1276
|
+
]
|
|
1277
|
+
if "stoppedEarly" in summary:
|
|
1278
|
+
lines.append(
|
|
1279
|
+
f"Stopped early: {summary['stoppedEarly']}. Traces that finished are saved in this test run."
|
|
1280
|
+
)
|
|
1281
|
+
else:
|
|
1282
|
+
lines.append(f"Replayed: {summary['replayed']}, errored: {summary['errored']}")
|
|
1283
|
+
with Path(os.environ["GITHUB_STEP_SUMMARY"]).open("a") as file:
|
|
1284
|
+
file.write("### Bitfab replay\n\n" + "\n\n".join(lines) + "\n")
|
|
1285
|
+
|
|
1286
|
+
|
|
1287
|
+
def run_replay(root, config, request, args, timeout, experiment):
|
|
1288
|
+
result = run_command(root, config, args, timeout, experiment)
|
|
1289
|
+
test_run = result.get("testRunId", result.get("test_run_id"))
|
|
1290
|
+
if not isinstance(test_run, str) or not UUID.fullmatch(test_run):
|
|
1291
|
+
raise ValueError("Replay did not return a valid persisted test run UUID")
|
|
1292
|
+
items = result.get("items")
|
|
1293
|
+
if not isinstance(items, list):
|
|
1294
|
+
raise ValueError("Replay did not return its replayed items")
|
|
1295
|
+
replayed = [item for item in items if not carried_over(item)]
|
|
1296
|
+
errors = item_errors(replayed)
|
|
1297
|
+
report_item_errors(errors)
|
|
1298
|
+
return {
|
|
1299
|
+
"executionId": request["id"],
|
|
1300
|
+
"commitSha": os.environ["GITHUB_SHA"],
|
|
1301
|
+
"testRunId": test_run,
|
|
1302
|
+
"replayed": len(replayed),
|
|
1303
|
+
"errored": len(errors),
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
|
|
1307
|
+
def carried_over(item):
|
|
1308
|
+
return isinstance(item, dict) and (
|
|
1309
|
+
item.get("carriedOver") is True or item.get("carried_over") is True
|
|
1310
|
+
)
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
def print_output_tail(text):
|
|
1314
|
+
tail = text[-OUTPUT_TAIL_LENGTH:].strip()
|
|
1315
|
+
if tail:
|
|
1316
|
+
print(
|
|
1317
|
+
"Last replay output:\n"
|
|
1318
|
+
+ "\n".join(" " + line for line in tail.splitlines()),
|
|
1319
|
+
file=sys.stderr,
|
|
1320
|
+
flush=True,
|
|
1321
|
+
)
|
|
1322
|
+
|
|
1323
|
+
|
|
1324
|
+
def run_command(root, config, args, timeout, experiment):
|
|
789
1325
|
with tempfile.TemporaryFile() as output:
|
|
790
1326
|
with subprocess.Popen(
|
|
791
1327
|
args,
|
|
792
1328
|
cwd=within(root, config["workingDirectory"]),
|
|
793
1329
|
stdout=output,
|
|
1330
|
+
stderr=subprocess.PIPE,
|
|
794
1331
|
stdin=subprocess.DEVNULL,
|
|
795
1332
|
start_new_session=True,
|
|
796
1333
|
) as child:
|
|
1334
|
+
reader = threading.Thread(
|
|
1335
|
+
target=forward_stderr, args=(child.stderr, experiment), daemon=True
|
|
1336
|
+
)
|
|
1337
|
+
reader.start()
|
|
797
1338
|
try:
|
|
798
|
-
|
|
1339
|
+
# Without --cloud-timeout the job's own timeout is the only limit.
|
|
1340
|
+
deadline = None if timeout is None else time.monotonic() + timeout * 60
|
|
799
1341
|
while child.poll() is None:
|
|
800
1342
|
if os.fstat(output.fileno()).st_size > 16 * 1024 * 1024:
|
|
801
1343
|
raise ValueError("Replay output exceeded 16 MiB")
|
|
802
|
-
if time.monotonic() >= deadline:
|
|
803
|
-
raise
|
|
1344
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
1345
|
+
raise ValueError(
|
|
1346
|
+
f"Replay stopped at the {timeout}-minute --cloud-timeout; traces that finished are saved in Bitfab as an interrupted experiment"
|
|
1347
|
+
)
|
|
804
1348
|
time.sleep(0.1)
|
|
805
1349
|
code = child.returncode
|
|
806
1350
|
except BaseException:
|
|
807
1351
|
with contextlib.suppress(ProcessLookupError):
|
|
808
1352
|
os.killpg(child.pid, signal.SIGTERM)
|
|
1353
|
+
# Time for the SDK to mark the experiment interrupted before the hard kill.
|
|
809
1354
|
with contextlib.suppress(subprocess.TimeoutExpired):
|
|
810
|
-
child.wait(timeout=
|
|
1355
|
+
child.wait(timeout=30)
|
|
811
1356
|
with contextlib.suppress(ProcessLookupError):
|
|
812
1357
|
os.killpg(child.pid, signal.SIGKILL)
|
|
813
1358
|
child.wait()
|
|
814
1359
|
raise
|
|
1360
|
+
finally:
|
|
1361
|
+
reader.join(timeout=5)
|
|
815
1362
|
if output.tell() > 16 * 1024 * 1024:
|
|
816
1363
|
raise ValueError("Replay output exceeded 16 MiB")
|
|
817
1364
|
output.seek(0)
|
|
818
|
-
text = output.read().decode()
|
|
1365
|
+
text = output.read().decode(errors="replace")
|
|
819
1366
|
if code:
|
|
820
|
-
|
|
1367
|
+
print_output_tail(text)
|
|
1368
|
+
raise ValueError(f"Replay command exited {code}; its output is above")
|
|
821
1369
|
decoder = json.JSONDecoder()
|
|
822
1370
|
result = None
|
|
823
1371
|
for offset in [0, *[i + 1 for i, value in enumerate(text) if value == "\n"]]:
|
|
@@ -828,30 +1376,10 @@ def execute():
|
|
|
828
1376
|
result = value
|
|
829
1377
|
except json.JSONDecodeError:
|
|
830
1378
|
pass
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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
|
|
1379
|
+
if result is None:
|
|
1380
|
+
print_output_tail(text)
|
|
1381
|
+
raise ValueError("Replay did not print a JSON result; its output is above")
|
|
1382
|
+
return result
|
|
855
1383
|
|
|
856
1384
|
|
|
857
1385
|
def item_errored(item):
|
|
@@ -892,18 +1420,77 @@ def report_errored_items(record):
|
|
|
892
1420
|
return 0
|
|
893
1421
|
|
|
894
1422
|
|
|
1423
|
+
def replay_step(config, cli_command, env):
|
|
1424
|
+
return {
|
|
1425
|
+
"name": "Replay",
|
|
1426
|
+
"working-directory": config["workingDirectory"],
|
|
1427
|
+
"run": shlex.join([*cli_command, "--cloud-execute"]),
|
|
1428
|
+
"env": {**env, **RUNNER_ENV},
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
|
|
1432
|
+
def workflow_document(job_options, setup_steps, replay):
|
|
1433
|
+
"""Only settings belong here; everything Bitfab may need to change runs inside the SDK."""
|
|
1434
|
+
job = {
|
|
1435
|
+
"runs-on": job_options.get("runsOn", "ubuntu-24.04"),
|
|
1436
|
+
"steps": [
|
|
1437
|
+
{
|
|
1438
|
+
"name": "Check out replay snapshot",
|
|
1439
|
+
"uses": "actions/checkout@11d5960a326750d5838078e36cf38b85af677262",
|
|
1440
|
+
"with": {"ref": "${{ github.sha }}", "persist-credentials": False},
|
|
1441
|
+
},
|
|
1442
|
+
*setup_steps,
|
|
1443
|
+
replay,
|
|
1444
|
+
],
|
|
1445
|
+
}
|
|
1446
|
+
for key in ("environment", "services"):
|
|
1447
|
+
if key in job_options:
|
|
1448
|
+
job[key] = job_options[key]
|
|
1449
|
+
return {
|
|
1450
|
+
"name": "Bitfab cloud replay",
|
|
1451
|
+
"run-name": RUN_NAME,
|
|
1452
|
+
"on": {
|
|
1453
|
+
"workflow_dispatch": {
|
|
1454
|
+
"inputs": {
|
|
1455
|
+
"execution_id": {"required": True, "type": "string"},
|
|
1456
|
+
"request": {"required": True, "type": "string"},
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
},
|
|
1460
|
+
"permissions": {"contents": "read"},
|
|
1461
|
+
"jobs": {"replay": job},
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
|
|
1465
|
+
def write_new_files(root, outputs):
|
|
1466
|
+
for name in outputs:
|
|
1467
|
+
if within(root, name).exists():
|
|
1468
|
+
raise ValueError(
|
|
1469
|
+
f"Refusing to overwrite {name}; review and edit the existing setup"
|
|
1470
|
+
)
|
|
1471
|
+
for name, content in outputs.items():
|
|
1472
|
+
target = within(root, name)
|
|
1473
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
1474
|
+
with target.open("x") as file:
|
|
1475
|
+
file.write(content)
|
|
1476
|
+
|
|
1477
|
+
|
|
895
1478
|
def initialize(argv):
|
|
896
1479
|
parser = argparse.ArgumentParser(
|
|
897
1480
|
prog="bitfab-replay --cloud-init",
|
|
898
|
-
description="Install direct GitHub cloud replay from a reviewed JSON setup specification
|
|
1481
|
+
description="Install direct GitHub cloud replay from a reviewed JSON setup specification, or bring an existing setup up to date in place. Safe to run again.",
|
|
899
1482
|
)
|
|
900
1483
|
parser.add_argument(
|
|
901
1484
|
"--config",
|
|
902
|
-
|
|
903
|
-
help="JSON file with cloud config, cliCommand, setupSteps, optional pipeline (omit or null to allow every registry pipeline), secrets, variables, environment, services, runsOn",
|
|
1485
|
+
help='JSON file with cloud config, cliCommand, setupSteps, optional pipeline (omit or null to allow every registry pipeline), secrets, variables, env (runner variable names mapped to {"secret": NAME} or {"variable": NAME} for renamed secrets and repository variables), checkCommand, environment, services, runsOn. Required for a new setup; for an existing one only cliCommand is read, and only when the workflow does not already show it',
|
|
904
1486
|
)
|
|
905
1487
|
args = parser.parse_args(argv)
|
|
906
|
-
spec = json.loads(Path(args.config).read_text())
|
|
1488
|
+
spec = json.loads(Path(args.config).read_text()) if args.config else None
|
|
1489
|
+
root = root_directory()
|
|
1490
|
+
if within(root, CONFIG).exists():
|
|
1491
|
+
return update_existing(root, spec)
|
|
1492
|
+
if spec is None:
|
|
1493
|
+
raise ValueError(f"No {CONFIG} yet; pass --config with a setup specification")
|
|
907
1494
|
config = {
|
|
908
1495
|
key: spec[key]
|
|
909
1496
|
for key in (
|
|
@@ -924,7 +1511,17 @@ def initialize(argv):
|
|
|
924
1511
|
secret_prefix = DEFAULT_SECRET_PREFIX
|
|
925
1512
|
config["secrets"] = secrets
|
|
926
1513
|
config["secretPrefix"] = secret_prefix
|
|
1514
|
+
if not isinstance(variables, list):
|
|
1515
|
+
raise ValueError("variables must be a list of names")
|
|
1516
|
+
env = dict(spec.get("env") or {})
|
|
1517
|
+
for name in variables:
|
|
1518
|
+
env.setdefault(name, {"variable": name})
|
|
1519
|
+
if env:
|
|
1520
|
+
config["env"] = env
|
|
1521
|
+
if spec.get("checkCommand") is not None:
|
|
1522
|
+
config["checkCommand"] = spec["checkCommand"]
|
|
927
1523
|
validate_config(config)
|
|
1524
|
+
validate_cli_command(spec.get("cliCommand"))
|
|
928
1525
|
if set(secrets) & set(variables):
|
|
929
1526
|
raise ValueError("Secret names and variable names must not overlap")
|
|
930
1527
|
if "BITFAB_API_KEY" not in secrets or not all(
|
|
@@ -933,16 +1530,6 @@ def initialize(argv):
|
|
|
933
1530
|
raise ValueError(
|
|
934
1531
|
"Supply uppercase secret/variable names, including BITFAB_API_KEY; never values"
|
|
935
1532
|
)
|
|
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
1533
|
steps = spec.get("setupSteps")
|
|
947
1534
|
if (
|
|
948
1535
|
not isinstance(steps, list)
|
|
@@ -950,81 +1537,145 @@ def initialize(argv):
|
|
|
950
1537
|
or not all(isinstance(step, dict) for step in steps)
|
|
951
1538
|
):
|
|
952
1539
|
raise ValueError("setupSteps must contain reviewed GitHub Actions setup steps")
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
env.update(
|
|
956
|
-
BITFAB_CLOUD_REQUEST="${{ inputs.request }}",
|
|
957
|
-
BITFAB_EXECUTION_ID="${{ inputs.execution_id }}",
|
|
958
|
-
BITFAB_COMMIT_SHA="${{ github.sha }}",
|
|
1540
|
+
workflow = workflow_document(
|
|
1541
|
+
spec, steps, replay_step(config, spec["cliCommand"], runner_env(config))
|
|
959
1542
|
)
|
|
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
1543
|
outputs = {
|
|
1008
1544
|
CONFIG: json.dumps(config, indent=2) + "\n",
|
|
1009
1545
|
f".github/workflows/{config['workflow']}": json.dumps(workflow, indent=2)
|
|
1010
1546
|
+ "\n",
|
|
1011
1547
|
}
|
|
1012
|
-
|
|
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)
|
|
1548
|
+
write_new_files(root, outputs)
|
|
1023
1549
|
return {
|
|
1024
1550
|
"files": list(outputs),
|
|
1025
|
-
"requiredSecrets":
|
|
1026
|
-
"requiredVariables":
|
|
1027
|
-
|
|
1551
|
+
"requiredSecrets": sorted(set(secret_targets(config).values())),
|
|
1552
|
+
"requiredVariables": sorted(
|
|
1553
|
+
source["variable"]
|
|
1554
|
+
for source in config.get("env", {}).values()
|
|
1555
|
+
if "variable" in source
|
|
1556
|
+
),
|
|
1557
|
+
"next": "Configure secrets securely, review push triggers, get the workflow registered with GitHub Actions (merging it to the default branch once does that), run --cloud-dry-run, then --cloud-check",
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
|
|
1561
|
+
def replace_file(target, content):
|
|
1562
|
+
with tempfile.NamedTemporaryFile(mode="w", dir=target.parent, delete=False) as file:
|
|
1563
|
+
file.write(content)
|
|
1564
|
+
temporary = file.name
|
|
1565
|
+
os.replace(temporary, target)
|
|
1566
|
+
|
|
1567
|
+
|
|
1568
|
+
def update_existing(root, spec):
|
|
1569
|
+
"""Existing files win: only the Replay step and steps older setups generated change."""
|
|
1570
|
+
config = json.loads(within(root, CONFIG).read_text())
|
|
1571
|
+
validate_config(config)
|
|
1572
|
+
name = f".github/workflows/{config['workflow']}"
|
|
1573
|
+
workflow_path = within(root, name)
|
|
1574
|
+
text = workflow_path.read_text() if workflow_path.is_file() else ""
|
|
1575
|
+
try:
|
|
1576
|
+
workflow = json.loads(text)
|
|
1577
|
+
job = workflow["jobs"]["replay"]
|
|
1578
|
+
steps = job["steps"]
|
|
1579
|
+
except (ValueError, KeyError, TypeError) as error:
|
|
1580
|
+
# A workflow the customer rewrote as YAML is current when nothing older remains.
|
|
1581
|
+
if (
|
|
1582
|
+
"--cloud-execute" in text
|
|
1583
|
+
and OLD_UPLOAD_STEP not in text
|
|
1584
|
+
and ".bitfab/cloudReplay.py" not in text
|
|
1585
|
+
and not re.search(rf"timeout-minutes:\s*{OLD_JOB_TIMEOUT}\b", text)
|
|
1586
|
+
):
|
|
1587
|
+
return {"files": [], "updated": False, "next": "Already up to date"}
|
|
1588
|
+
raise ValueError(
|
|
1589
|
+
f"{name} is missing or was not generated by --cloud-init; edit its Replay step by hand to run the SDK's bitfab-replay command with --cloud-execute"
|
|
1590
|
+
) from error
|
|
1591
|
+
replay = [
|
|
1592
|
+
index
|
|
1593
|
+
for index, step in enumerate(steps)
|
|
1594
|
+
if isinstance(step, dict) and step.get("name") == "Replay"
|
|
1595
|
+
]
|
|
1596
|
+
if len(replay) != 1:
|
|
1597
|
+
raise ValueError(f"{name} must contain exactly one step named Replay")
|
|
1598
|
+
step = steps[replay[0]]
|
|
1599
|
+
words = shlex.split(step.get("run", ""))
|
|
1600
|
+
if spec is not None and spec.get("cliCommand") is not None:
|
|
1601
|
+
cli_command = spec["cliCommand"]
|
|
1602
|
+
elif words[-1:] == ["--cloud-execute"]:
|
|
1603
|
+
cli_command = words[:-1]
|
|
1604
|
+
elif config.get("cliCommand") is not None:
|
|
1605
|
+
cli_command = config["cliCommand"]
|
|
1606
|
+
else:
|
|
1607
|
+
raise ValueError(
|
|
1608
|
+
"The Replay step does not run the SDK command; pass --config with a file whose cliCommand starts bitfab-replay from workingDirectory"
|
|
1609
|
+
)
|
|
1610
|
+
validate_cli_command(cli_command)
|
|
1611
|
+
env = {
|
|
1612
|
+
key: value
|
|
1613
|
+
for key, value in (step.get("env") or {}).items()
|
|
1614
|
+
if key not in RUNNER_ENV
|
|
1615
|
+
}
|
|
1616
|
+
declared = runner_env(config)
|
|
1617
|
+
conflicts = sorted(
|
|
1618
|
+
name for name in declared if name in env and env[name] != declared[name]
|
|
1619
|
+
)
|
|
1620
|
+
undeclared = sorted(name for name in env if name not in declared)
|
|
1621
|
+
env = {**declared, **env}
|
|
1622
|
+
extra = {
|
|
1623
|
+
key: value
|
|
1624
|
+
for key, value in step.items()
|
|
1625
|
+
if key not in ("name", "uses", "with", "run", "working-directory", "env")
|
|
1626
|
+
}
|
|
1627
|
+
before = json.dumps([config, workflow], sort_keys=True)
|
|
1628
|
+
# Edit in place so the customer's checkout options, extra steps, and job
|
|
1629
|
+
# settings survive; only the Replay step and our old result upload change.
|
|
1630
|
+
job["steps"] = [
|
|
1631
|
+
{**replay_step(config, cli_command, env), **extra}
|
|
1632
|
+
if index == replay[0]
|
|
1633
|
+
else entry
|
|
1634
|
+
for index, entry in enumerate(steps)
|
|
1635
|
+
if not (
|
|
1636
|
+
isinstance(entry, dict)
|
|
1637
|
+
and entry.get("name") == OLD_UPLOAD_STEP
|
|
1638
|
+
and str(entry.get("uses", "")).startswith("actions/upload-artifact@")
|
|
1639
|
+
)
|
|
1640
|
+
]
|
|
1641
|
+
# The limit older setups generated; a value the customer chose stays.
|
|
1642
|
+
if job.get("timeout-minutes") == OLD_JOB_TIMEOUT:
|
|
1643
|
+
del job["timeout-minutes"]
|
|
1644
|
+
config.pop("cliCommand", None)
|
|
1645
|
+
# Setups before the SDK carried the script left a copy that nothing reads now.
|
|
1646
|
+
old_script = ".bitfab/cloudReplay.py"
|
|
1647
|
+
removable = [old_script] if within(root, old_script).exists() else []
|
|
1648
|
+
mismatches = {}
|
|
1649
|
+
if conflicts:
|
|
1650
|
+
mismatches["conflicts"] = conflicts
|
|
1651
|
+
if undeclared:
|
|
1652
|
+
mismatches["undeclared"] = undeclared
|
|
1653
|
+
if mismatches:
|
|
1654
|
+
mismatches["mismatchNext"] = (
|
|
1655
|
+
"The Replay step sets these differently from, or in addition to, .bitfab/cloud.json. "
|
|
1656
|
+
'Describe each one in cloud.json "env" as {"secret": NAME} or {"variable": NAME} '
|
|
1657
|
+
"so --cloud-secrets and the runner's empty-secret check see it; the step was left as it is"
|
|
1658
|
+
)
|
|
1659
|
+
if json.dumps([config, workflow], sort_keys=True) == before:
|
|
1660
|
+
return {
|
|
1661
|
+
"files": [],
|
|
1662
|
+
"updated": False,
|
|
1663
|
+
"removable": removable,
|
|
1664
|
+
**mismatches,
|
|
1665
|
+
"next": "Already up to date",
|
|
1666
|
+
}
|
|
1667
|
+
outputs = {
|
|
1668
|
+
CONFIG: json.dumps(config, indent=2) + "\n",
|
|
1669
|
+
name: json.dumps(workflow, indent=2) + "\n",
|
|
1670
|
+
}
|
|
1671
|
+
for output, content in outputs.items():
|
|
1672
|
+
replace_file(within(root, output), content)
|
|
1673
|
+
return {
|
|
1674
|
+
"files": list(outputs),
|
|
1675
|
+
"updated": True,
|
|
1676
|
+
"removable": removable,
|
|
1677
|
+
**mismatches,
|
|
1678
|
+
"next": "Review the diff, then run --cloud-dry-run; replays use the workflow in their own snapshot, so the change applies without merging",
|
|
1028
1679
|
}
|
|
1029
1680
|
|
|
1030
1681
|
|
|
@@ -1040,9 +1691,26 @@ def main():
|
|
|
1040
1691
|
result = run_cli(sys.argv[1:])
|
|
1041
1692
|
print(json.dumps(result, indent=2))
|
|
1042
1693
|
if result.get("state") == "completed" and result.get("conclusion") != "success":
|
|
1694
|
+
if result.get("request", {}).get("check"):
|
|
1695
|
+
print(
|
|
1696
|
+
f"Cloud check failed. The Replay step log names what is missing: {result.get('url')}",
|
|
1697
|
+
file=sys.stderr,
|
|
1698
|
+
)
|
|
1699
|
+
elif result.get("testRunId"):
|
|
1700
|
+
print(
|
|
1701
|
+
f"Cloud replay stopped early ({result.get('stoppedEarly', result.get('conclusion'))}). Traces that finished are saved in test run {result['testRunId']}.",
|
|
1702
|
+
file=sys.stderr,
|
|
1703
|
+
)
|
|
1043
1704
|
return 1
|
|
1044
1705
|
if result.get("state") == "completed":
|
|
1045
|
-
|
|
1706
|
+
code = report_errored_items(result)
|
|
1707
|
+
if result.get("failOnError") and result.get("errored"):
|
|
1708
|
+
print(
|
|
1709
|
+
"Cloud replay: exiting 1 because of --fail-on-error",
|
|
1710
|
+
file=sys.stderr,
|
|
1711
|
+
)
|
|
1712
|
+
return 1
|
|
1713
|
+
return code
|
|
1046
1714
|
return 0
|
|
1047
1715
|
except KeyboardInterrupt:
|
|
1048
1716
|
print(
|