bitfab 0.62.4 → 0.62.6
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 +374 -125
- package/dist/{chunk-IHJDEWXY.js → chunk-5QT34T7B.js} +6 -3
- package/dist/{chunk-IHJDEWXY.js.map → chunk-5QT34T7B.js.map} +1 -1
- package/dist/{chunk-CO2HRQEG.js → chunk-C2R3NEPK.js} +4 -3
- package/dist/chunk-C2R3NEPK.js.map +1 -0
- package/dist/{chunk-4KCUJ5WL.js → chunk-CP7J3VTC.js} +78 -6
- package/dist/chunk-CP7J3VTC.js.map +1 -0
- package/dist/{chunk-GXD74YMV.js → chunk-LXTDXWNK.js} +21 -3
- package/dist/chunk-LXTDXWNK.js.map +1 -0
- package/dist/{chunk-GWX7C2CB.js → chunk-PIEBUHL2.js} +6 -3
- package/dist/{chunk-GWX7C2CB.js.map → chunk-PIEBUHL2.js.map} +1 -1
- package/dist/{http-4T6FFHWZ.js → http-DJRQ2SRF.js} +2 -2
- package/dist/{http-NSUBKKB5.js → http-OF2227WS.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-I3ENLBXT.js → replay-SVKFFDNU.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-4KCUJ5WL.js.map +0 -1
- package/dist/chunk-CO2HRQEG.js.map +0 -1
- package/dist/chunk-GXD74YMV.js.map +0 -1
- /package/dist/{http-4T6FFHWZ.js.map → http-DJRQ2SRF.js.map} +0 -0
- /package/dist/{http-NSUBKKB5.js.map → http-OF2227WS.js.map} +0 -0
- /package/dist/{replay-I3ENLBXT.js.map → replay-SVKFFDNU.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,17 @@ 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}):")
|
|
33
50
|
HELP = """GitHub cloud replay (requires git, gh login, and Python 3.10+).
|
|
34
51
|
--cloud PIPELINE --trace-ids UUID[,UUID] [--registry PATH]
|
|
35
52
|
[--max-concurrency 1..32] [--cloud-request-id UUID]
|
|
36
53
|
[--cloud-include PATH ...] [--cloud-dry-run] [--cloud-detach]
|
|
54
|
+
[--cloud-timeout MINUTES]
|
|
37
55
|
--cloud-status UUID | --cloud-watch UUID | --cloud-cancel UUID
|
|
38
56
|
--cloud-cleanup UUID
|
|
39
|
-
--cloud-init --config SPEC
|
|
57
|
+
--cloud-init [--config SPEC] (creates a setup, or brings an existing one up to date)
|
|
58
|
+
--cloud-secrets --env-file FILE [NAME ...]
|
|
40
59
|
PIPELINE may be any pipeline in the registry, unless .bitfab/cloud.json names one.
|
|
41
60
|
Snapshot tracked working files without changing HEAD, the index, or local files.
|
|
42
61
|
New files require explicit --cloud-include. Credentials and ignored files are refused.
|
|
@@ -266,6 +285,8 @@ def validate_config(config):
|
|
|
266
285
|
raise ValueError(
|
|
267
286
|
"The runner command must execute locally, not recursively dispatch"
|
|
268
287
|
)
|
|
288
|
+
if "cliCommand" in config:
|
|
289
|
+
validate_cli_command(config["cliCommand"])
|
|
269
290
|
if config.get("pushTriggersReviewed") is not True:
|
|
270
291
|
raise ValueError(
|
|
271
292
|
"Setup must review push-triggered CI/deployments and set pushTriggersReviewed=true"
|
|
@@ -285,6 +306,18 @@ def validate_config(config):
|
|
|
285
306
|
raise ValueError("secrets must be uppercase environment variable names")
|
|
286
307
|
|
|
287
308
|
|
|
309
|
+
def validate_cli_command(cli_command):
|
|
310
|
+
if (
|
|
311
|
+
not isinstance(cli_command, list)
|
|
312
|
+
or not cli_command
|
|
313
|
+
or not all(isinstance(v, str) and v and "\x00" not in v for v in cli_command)
|
|
314
|
+
or any(v.startswith("--cloud") for v in cli_command)
|
|
315
|
+
):
|
|
316
|
+
raise ValueError(
|
|
317
|
+
"cliCommand must be the argument array that starts the SDK's bitfab-replay command from workingDirectory"
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
|
|
288
321
|
def check_pipeline(config, pipeline):
|
|
289
322
|
if not isinstance(pipeline, str) or not PIPELINE.fullmatch(pipeline):
|
|
290
323
|
raise ValueError("Invalid pipeline name")
|
|
@@ -342,6 +375,7 @@ def parse(argv):
|
|
|
342
375
|
parser.add_argument("--cloud-include", action="append", default=[])
|
|
343
376
|
parser.add_argument("--cloud-dry-run", action="store_true")
|
|
344
377
|
parser.add_argument("--cloud-detach", action="store_true")
|
|
378
|
+
parser.add_argument("--cloud-timeout", type=int)
|
|
345
379
|
args = parser.parse_args(argv)
|
|
346
380
|
operations = [
|
|
347
381
|
name
|
|
@@ -363,6 +397,9 @@ def parse(argv):
|
|
|
363
397
|
raise ValueError("Supply 1..100 explicit trace UUIDs")
|
|
364
398
|
if not 1 <= args.max_concurrency <= 32:
|
|
365
399
|
raise ValueError("--max-concurrency must be 1..32")
|
|
400
|
+
# Self-hosted runners allow jobs of up to 5 days; GitHub-hosted ones stop at 6 hours.
|
|
401
|
+
if args.cloud_timeout is not None and not 1 <= args.cloud_timeout <= 7200:
|
|
402
|
+
raise ValueError("--cloud-timeout must be 1..7200 minutes")
|
|
366
403
|
if not UUID.fullmatch(execution_id):
|
|
367
404
|
raise ValueError("Execution ID must be a UUID")
|
|
368
405
|
return args, operation, execution_id
|
|
@@ -547,39 +584,82 @@ def status(record, *, fetch_result=True):
|
|
|
547
584
|
if (
|
|
548
585
|
fetch_result
|
|
549
586
|
and record["state"] == "completed"
|
|
550
|
-
and record["conclusion"] == "success"
|
|
551
587
|
and not record.get("testRunId")
|
|
588
|
+
and not record.get("resultChecked")
|
|
552
589
|
):
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
)
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
590
|
+
success = record["conclusion"] == "success"
|
|
591
|
+
try:
|
|
592
|
+
result = read_result(record)
|
|
593
|
+
except RuntimeError:
|
|
594
|
+
# A run that failed before its replay started reports no result.
|
|
595
|
+
if success:
|
|
596
|
+
raise
|
|
597
|
+
record["resultChecked"] = True
|
|
598
|
+
return record
|
|
599
|
+
if (
|
|
600
|
+
result.get("executionId") != record["id"]
|
|
601
|
+
or result.get("commitSha") != record["sha"]
|
|
602
|
+
or not UUID.fullmatch(result.get("testRunId", ""))
|
|
603
|
+
):
|
|
604
|
+
raise ValueError("Replay result does not match this execution")
|
|
605
|
+
counts = replay_counts(result)
|
|
606
|
+
record["testRunId"] = result["testRunId"]
|
|
607
|
+
if isinstance(result.get("stoppedEarly"), str):
|
|
608
|
+
record["stoppedEarly"] = result["stoppedEarly"]
|
|
609
|
+
if counts is not None:
|
|
610
|
+
record["replayed"], record["errored"] = counts
|
|
611
|
+
return record
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def read_result(record):
|
|
615
|
+
try:
|
|
616
|
+
result = read_result_annotation(record)
|
|
617
|
+
except RuntimeError:
|
|
618
|
+
# Older workflows still upload the result, so a failed Checks API read
|
|
619
|
+
# must not hide it.
|
|
620
|
+
result = None
|
|
621
|
+
return result or read_result_artifact(record)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def read_result_annotation(record):
|
|
625
|
+
repo = record["repository"]
|
|
626
|
+
jobs = api(repo, f"actions/runs/{record['runId']}/jobs?per_page=100") or {}
|
|
627
|
+
for job in jobs.get("jobs", []):
|
|
628
|
+
check_run = str(job.get("check_run_url", "")).rsplit("/", 1)[-1]
|
|
629
|
+
if not check_run.isdigit():
|
|
630
|
+
continue
|
|
631
|
+
for note in api(repo, f"check-runs/{check_run}/annotations?per_page=100") or []:
|
|
572
632
|
if (
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
or not UUID.fullmatch(result.get("testRunId", ""))
|
|
633
|
+
note.get("title") == RESULT_TITLE
|
|
634
|
+
and len(note.get("message", "")) <= 4096
|
|
576
635
|
):
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
636
|
+
try:
|
|
637
|
+
return json.loads(base64.b64decode(note["message"], validate=True))
|
|
638
|
+
except ValueError as error:
|
|
639
|
+
raise ValueError("Invalid replay result annotation") from error
|
|
640
|
+
return None
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def read_result_artifact(record):
|
|
644
|
+
with tempfile.TemporaryDirectory(prefix="bitfab-cloud-result-") as directory:
|
|
645
|
+
command(
|
|
646
|
+
[
|
|
647
|
+
"gh",
|
|
648
|
+
"run",
|
|
649
|
+
"download",
|
|
650
|
+
str(record["runId"]),
|
|
651
|
+
"--repo",
|
|
652
|
+
record["repository"],
|
|
653
|
+
"--name",
|
|
654
|
+
"bitfab-replay-" + record["id"],
|
|
655
|
+
"--dir",
|
|
656
|
+
directory,
|
|
657
|
+
]
|
|
658
|
+
)
|
|
659
|
+
artifact = Path(directory) / RESULT_FILE
|
|
660
|
+
if artifact.is_symlink() or artifact.stat().st_size > 4096:
|
|
661
|
+
raise ValueError("Invalid replay result artifact")
|
|
662
|
+
return json.loads(artifact.read_text())
|
|
583
663
|
|
|
584
664
|
|
|
585
665
|
def cleanup(root, record):
|
|
@@ -643,6 +723,8 @@ def run_cli(argv):
|
|
|
643
723
|
"traceIds": args.trace_ids.split(","),
|
|
644
724
|
"maxConcurrency": args.max_concurrency,
|
|
645
725
|
}
|
|
726
|
+
if args.cloud_timeout is not None:
|
|
727
|
+
request["timeoutMinutes"] = args.cloud_timeout
|
|
646
728
|
if args.cloud_dry_run:
|
|
647
729
|
return {
|
|
648
730
|
"dryRun": True,
|
|
@@ -729,17 +811,24 @@ def run_cli(argv):
|
|
|
729
811
|
save(path, record)
|
|
730
812
|
return record
|
|
731
813
|
if operation == "watch" or (operation == "submit" and not args.cloud_detach):
|
|
732
|
-
|
|
733
|
-
|
|
814
|
+
# Once GitHub shows the run, its job timeout bounds the wait and Ctrl-C detaches.
|
|
815
|
+
# A dispatch GitHub never shows as a run would otherwise be waited on forever.
|
|
816
|
+
started = time.monotonic()
|
|
817
|
+
while record["state"] != "completed":
|
|
734
818
|
status(record)
|
|
735
819
|
save(path, record)
|
|
820
|
+
if (
|
|
821
|
+
record["state"] == "dispatch_unknown"
|
|
822
|
+
and time.monotonic() - started >= 10 * 60
|
|
823
|
+
):
|
|
824
|
+
raise ValueError(
|
|
825
|
+
"GitHub has shown no run for this dispatch after 10 minutes. Check Actions, then resume with --cloud-watch "
|
|
826
|
+
+ execution_id
|
|
827
|
+
+ " or remove the snapshot branch with --cloud-cleanup "
|
|
828
|
+
+ execution_id
|
|
829
|
+
)
|
|
736
830
|
if record["state"] != "completed":
|
|
737
831
|
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
832
|
if record["state"] == "completed" or (
|
|
744
833
|
operation == "cleanup" and record["state"] == "prepared"
|
|
745
834
|
):
|
|
@@ -763,6 +852,7 @@ def execute():
|
|
|
763
852
|
check_pipeline(config, request["pipeline"])
|
|
764
853
|
if request["id"] != os.environ["BITFAB_EXECUTION_ID"]:
|
|
765
854
|
raise ValueError("Replay request does not match the configured execution")
|
|
855
|
+
timeout = request.get("timeoutMinutes")
|
|
766
856
|
parse(
|
|
767
857
|
[
|
|
768
858
|
"--cloud",
|
|
@@ -773,6 +863,7 @@ def execute():
|
|
|
773
863
|
str(request["maxConcurrency"]),
|
|
774
864
|
"--cloud-request-id",
|
|
775
865
|
request["id"],
|
|
866
|
+
*([] if timeout is None else ["--cloud-timeout", str(timeout)]),
|
|
776
867
|
]
|
|
777
868
|
)
|
|
778
869
|
if not os.environ.get("BITFAB_API_KEY"):
|
|
@@ -786,32 +877,100 @@ def execute():
|
|
|
786
877
|
str(request["maxConcurrency"]),
|
|
787
878
|
"--no-code-change",
|
|
788
879
|
]
|
|
880
|
+
experiment = {}
|
|
881
|
+
# GitHub cancels and times out a job with SIGINT then SIGTERM; turn SIGTERM into the
|
|
882
|
+
# same interrupt so the replay is stopped cleanly and its experiment is still reported.
|
|
883
|
+
previous = signal.signal(signal.SIGTERM, raise_interrupt)
|
|
884
|
+
try:
|
|
885
|
+
summary = run_replay(root, config, request, args, timeout, experiment)
|
|
886
|
+
except BaseException as error:
|
|
887
|
+
if UUID.fullmatch(experiment.get("id", "")):
|
|
888
|
+
write_result(
|
|
889
|
+
{
|
|
890
|
+
"executionId": request["id"],
|
|
891
|
+
"commitSha": os.environ["GITHUB_SHA"],
|
|
892
|
+
"testRunId": experiment["id"],
|
|
893
|
+
"stoppedEarly": (str(error) or type(error).__name__)[:300],
|
|
894
|
+
}
|
|
895
|
+
)
|
|
896
|
+
raise
|
|
897
|
+
finally:
|
|
898
|
+
signal.signal(signal.SIGTERM, previous)
|
|
899
|
+
write_result(summary)
|
|
900
|
+
return summary
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def raise_interrupt(signum, frame):
|
|
904
|
+
raise KeyboardInterrupt
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def forward_stderr(stream, experiment):
|
|
908
|
+
for line in iter(stream.readline, b""):
|
|
909
|
+
sys.stderr.buffer.write(line)
|
|
910
|
+
sys.stderr.flush()
|
|
911
|
+
if "id" not in experiment:
|
|
912
|
+
match = EXPERIMENT_LINE.match(line)
|
|
913
|
+
if match:
|
|
914
|
+
experiment["id"] = match[1].decode()
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def write_result(summary):
|
|
918
|
+
# Base64, because GitHub masks every line of a multi-line secret in logs and
|
|
919
|
+
# annotations, so a JSON credential alone turns each { and } into ***.
|
|
920
|
+
message = base64.b64encode(json.dumps(summary).encode()).decode()
|
|
921
|
+
print(f"::notice title={RESULT_TITLE}::{message}", flush=True)
|
|
922
|
+
lines = [
|
|
923
|
+
f"Test run: `{summary['testRunId']}`",
|
|
924
|
+
f"Commit: `{summary['commitSha']}`",
|
|
925
|
+
]
|
|
926
|
+
if "stoppedEarly" in summary:
|
|
927
|
+
lines.append(
|
|
928
|
+
f"Stopped early: {summary['stoppedEarly']}. Traces that finished are saved in this test run."
|
|
929
|
+
)
|
|
930
|
+
else:
|
|
931
|
+
lines.append(f"Replayed: {summary['replayed']}, errored: {summary['errored']}")
|
|
932
|
+
with Path(os.environ["GITHUB_STEP_SUMMARY"]).open("a") as file:
|
|
933
|
+
file.write("### Bitfab replay\n\n" + "\n\n".join(lines) + "\n")
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def run_replay(root, config, request, args, timeout, experiment):
|
|
789
937
|
with tempfile.TemporaryFile() as output:
|
|
790
938
|
with subprocess.Popen(
|
|
791
939
|
args,
|
|
792
940
|
cwd=within(root, config["workingDirectory"]),
|
|
793
941
|
stdout=output,
|
|
942
|
+
stderr=subprocess.PIPE,
|
|
794
943
|
stdin=subprocess.DEVNULL,
|
|
795
944
|
start_new_session=True,
|
|
796
945
|
) as child:
|
|
946
|
+
reader = threading.Thread(
|
|
947
|
+
target=forward_stderr, args=(child.stderr, experiment), daemon=True
|
|
948
|
+
)
|
|
949
|
+
reader.start()
|
|
797
950
|
try:
|
|
798
|
-
|
|
951
|
+
# Without --cloud-timeout the job's own timeout is the only limit.
|
|
952
|
+
deadline = None if timeout is None else time.monotonic() + timeout * 60
|
|
799
953
|
while child.poll() is None:
|
|
800
954
|
if os.fstat(output.fileno()).st_size > 16 * 1024 * 1024:
|
|
801
955
|
raise ValueError("Replay output exceeded 16 MiB")
|
|
802
|
-
if time.monotonic() >= deadline:
|
|
803
|
-
raise
|
|
956
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
957
|
+
raise ValueError(
|
|
958
|
+
f"Replay stopped at the {timeout}-minute --cloud-timeout; traces that finished are saved in Bitfab as an interrupted experiment"
|
|
959
|
+
)
|
|
804
960
|
time.sleep(0.1)
|
|
805
961
|
code = child.returncode
|
|
806
962
|
except BaseException:
|
|
807
963
|
with contextlib.suppress(ProcessLookupError):
|
|
808
964
|
os.killpg(child.pid, signal.SIGTERM)
|
|
965
|
+
# Time for the SDK to mark the experiment interrupted before the hard kill.
|
|
809
966
|
with contextlib.suppress(subprocess.TimeoutExpired):
|
|
810
|
-
child.wait(timeout=
|
|
967
|
+
child.wait(timeout=30)
|
|
811
968
|
with contextlib.suppress(ProcessLookupError):
|
|
812
969
|
os.killpg(child.pid, signal.SIGKILL)
|
|
813
970
|
child.wait()
|
|
814
971
|
raise
|
|
972
|
+
finally:
|
|
973
|
+
reader.join(timeout=5)
|
|
815
974
|
if output.tell() > 16 * 1024 * 1024:
|
|
816
975
|
raise ValueError("Replay output exceeded 16 MiB")
|
|
817
976
|
output.seek(0)
|
|
@@ -843,14 +1002,6 @@ def execute():
|
|
|
843
1002
|
"replayed": len(items),
|
|
844
1003
|
"errored": sum(1 for item in items if item_errored(item)),
|
|
845
1004
|
}
|
|
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
1005
|
return summary
|
|
855
1006
|
|
|
856
1007
|
|
|
@@ -892,18 +1043,77 @@ def report_errored_items(record):
|
|
|
892
1043
|
return 0
|
|
893
1044
|
|
|
894
1045
|
|
|
1046
|
+
def replay_step(config, cli_command, env):
|
|
1047
|
+
return {
|
|
1048
|
+
"name": "Replay",
|
|
1049
|
+
"working-directory": config["workingDirectory"],
|
|
1050
|
+
"run": shlex.join([*cli_command, "--cloud-execute"]),
|
|
1051
|
+
"env": {**env, **RUNNER_ENV},
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def workflow_document(job_options, setup_steps, replay):
|
|
1056
|
+
"""Only settings belong here; everything Bitfab may need to change runs inside the SDK."""
|
|
1057
|
+
job = {
|
|
1058
|
+
"runs-on": job_options.get("runsOn", "ubuntu-24.04"),
|
|
1059
|
+
"steps": [
|
|
1060
|
+
{
|
|
1061
|
+
"name": "Check out replay snapshot",
|
|
1062
|
+
"uses": "actions/checkout@11d5960a326750d5838078e36cf38b85af677262",
|
|
1063
|
+
"with": {"ref": "${{ github.sha }}", "persist-credentials": False},
|
|
1064
|
+
},
|
|
1065
|
+
*setup_steps,
|
|
1066
|
+
replay,
|
|
1067
|
+
],
|
|
1068
|
+
}
|
|
1069
|
+
for key in ("environment", "services"):
|
|
1070
|
+
if key in job_options:
|
|
1071
|
+
job[key] = job_options[key]
|
|
1072
|
+
return {
|
|
1073
|
+
"name": "Bitfab cloud replay",
|
|
1074
|
+
"run-name": RUN_NAME,
|
|
1075
|
+
"on": {
|
|
1076
|
+
"workflow_dispatch": {
|
|
1077
|
+
"inputs": {
|
|
1078
|
+
"execution_id": {"required": True, "type": "string"},
|
|
1079
|
+
"request": {"required": True, "type": "string"},
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
},
|
|
1083
|
+
"permissions": {"contents": "read"},
|
|
1084
|
+
"jobs": {"replay": job},
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
def write_new_files(root, outputs):
|
|
1089
|
+
for name in outputs:
|
|
1090
|
+
if within(root, name).exists():
|
|
1091
|
+
raise ValueError(
|
|
1092
|
+
f"Refusing to overwrite {name}; review and edit the existing setup"
|
|
1093
|
+
)
|
|
1094
|
+
for name, content in outputs.items():
|
|
1095
|
+
target = within(root, name)
|
|
1096
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
1097
|
+
with target.open("x") as file:
|
|
1098
|
+
file.write(content)
|
|
1099
|
+
|
|
1100
|
+
|
|
895
1101
|
def initialize(argv):
|
|
896
1102
|
parser = argparse.ArgumentParser(
|
|
897
1103
|
prog="bitfab-replay --cloud-init",
|
|
898
|
-
description="Install direct GitHub cloud replay from a reviewed JSON setup specification
|
|
1104
|
+
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
1105
|
)
|
|
900
1106
|
parser.add_argument(
|
|
901
1107
|
"--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",
|
|
1108
|
+
help="JSON file with cloud config, cliCommand, setupSteps, optional pipeline (omit or null to allow every registry pipeline), secrets, variables, 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
1109
|
)
|
|
905
1110
|
args = parser.parse_args(argv)
|
|
906
|
-
spec = json.loads(Path(args.config).read_text())
|
|
1111
|
+
spec = json.loads(Path(args.config).read_text()) if args.config else None
|
|
1112
|
+
root = root_directory()
|
|
1113
|
+
if within(root, CONFIG).exists():
|
|
1114
|
+
return update_existing(root, spec)
|
|
1115
|
+
if spec is None:
|
|
1116
|
+
raise ValueError(f"No {CONFIG} yet; pass --config with a setup specification")
|
|
907
1117
|
config = {
|
|
908
1118
|
key: spec[key]
|
|
909
1119
|
for key in (
|
|
@@ -925,6 +1135,7 @@ def initialize(argv):
|
|
|
925
1135
|
config["secrets"] = secrets
|
|
926
1136
|
config["secretPrefix"] = secret_prefix
|
|
927
1137
|
validate_config(config)
|
|
1138
|
+
validate_cli_command(spec.get("cliCommand"))
|
|
928
1139
|
if set(secrets) & set(variables):
|
|
929
1140
|
raise ValueError("Secret names and variable names must not overlap")
|
|
930
1141
|
if "BITFAB_API_KEY" not in secrets or not all(
|
|
@@ -933,16 +1144,6 @@ def initialize(argv):
|
|
|
933
1144
|
raise ValueError(
|
|
934
1145
|
"Supply uppercase secret/variable names, including BITFAB_API_KEY; never values"
|
|
935
1146
|
)
|
|
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
1147
|
steps = spec.get("setupSteps")
|
|
947
1148
|
if (
|
|
948
1149
|
not isinstance(steps, list)
|
|
@@ -952,74 +1153,15 @@ def initialize(argv):
|
|
|
952
1153
|
raise ValueError("setupSteps must contain reviewed GitHub Actions setup steps")
|
|
953
1154
|
env = {key: "${{ secrets." + secret_prefix + key + " }}" for key in secrets}
|
|
954
1155
|
env.update({key: "${{ vars." + key + " }}" for key in variables})
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
BITFAB_EXECUTION_ID="${{ inputs.execution_id }}",
|
|
958
|
-
BITFAB_COMMIT_SHA="${{ github.sha }}",
|
|
1156
|
+
workflow = workflow_document(
|
|
1157
|
+
spec, steps, replay_step(config, spec["cliCommand"], env)
|
|
959
1158
|
)
|
|
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
1159
|
outputs = {
|
|
1008
1160
|
CONFIG: json.dumps(config, indent=2) + "\n",
|
|
1009
1161
|
f".github/workflows/{config['workflow']}": json.dumps(workflow, indent=2)
|
|
1010
1162
|
+ "\n",
|
|
1011
1163
|
}
|
|
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)
|
|
1164
|
+
write_new_files(root, outputs)
|
|
1023
1165
|
return {
|
|
1024
1166
|
"files": list(outputs),
|
|
1025
1167
|
"requiredSecrets": [secret_prefix + name for name in secrets],
|
|
@@ -1028,6 +1170,108 @@ def initialize(argv):
|
|
|
1028
1170
|
}
|
|
1029
1171
|
|
|
1030
1172
|
|
|
1173
|
+
def replace_file(target, content):
|
|
1174
|
+
with tempfile.NamedTemporaryFile(mode="w", dir=target.parent, delete=False) as file:
|
|
1175
|
+
file.write(content)
|
|
1176
|
+
temporary = file.name
|
|
1177
|
+
os.replace(temporary, target)
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
def update_existing(root, spec):
|
|
1181
|
+
"""Existing files win: only the Replay step and steps older setups generated change."""
|
|
1182
|
+
config = json.loads(within(root, CONFIG).read_text())
|
|
1183
|
+
validate_config(config)
|
|
1184
|
+
name = f".github/workflows/{config['workflow']}"
|
|
1185
|
+
workflow_path = within(root, name)
|
|
1186
|
+
text = workflow_path.read_text() if workflow_path.is_file() else ""
|
|
1187
|
+
try:
|
|
1188
|
+
workflow = json.loads(text)
|
|
1189
|
+
job = workflow["jobs"]["replay"]
|
|
1190
|
+
steps = job["steps"]
|
|
1191
|
+
except (ValueError, KeyError, TypeError) as error:
|
|
1192
|
+
# A workflow the customer rewrote as YAML is current when nothing older remains.
|
|
1193
|
+
if (
|
|
1194
|
+
"--cloud-execute" in text
|
|
1195
|
+
and OLD_UPLOAD_STEP not in text
|
|
1196
|
+
and ".bitfab/cloudReplay.py" not in text
|
|
1197
|
+
and not re.search(rf"timeout-minutes:\s*{OLD_JOB_TIMEOUT}\b", text)
|
|
1198
|
+
):
|
|
1199
|
+
return {"files": [], "updated": False, "next": "Already up to date"}
|
|
1200
|
+
raise ValueError(
|
|
1201
|
+
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"
|
|
1202
|
+
) from error
|
|
1203
|
+
replay = [
|
|
1204
|
+
index
|
|
1205
|
+
for index, step in enumerate(steps)
|
|
1206
|
+
if isinstance(step, dict) and step.get("name") == "Replay"
|
|
1207
|
+
]
|
|
1208
|
+
if len(replay) != 1:
|
|
1209
|
+
raise ValueError(f"{name} must contain exactly one step named Replay")
|
|
1210
|
+
step = steps[replay[0]]
|
|
1211
|
+
words = shlex.split(step.get("run", ""))
|
|
1212
|
+
if spec is not None and spec.get("cliCommand") is not None:
|
|
1213
|
+
cli_command = spec["cliCommand"]
|
|
1214
|
+
elif words[-1:] == ["--cloud-execute"]:
|
|
1215
|
+
cli_command = words[:-1]
|
|
1216
|
+
elif config.get("cliCommand") is not None:
|
|
1217
|
+
cli_command = config["cliCommand"]
|
|
1218
|
+
else:
|
|
1219
|
+
raise ValueError(
|
|
1220
|
+
"The Replay step does not run the SDK command; pass --config with a file whose cliCommand starts bitfab-replay from workingDirectory"
|
|
1221
|
+
)
|
|
1222
|
+
validate_cli_command(cli_command)
|
|
1223
|
+
env = {
|
|
1224
|
+
key: value
|
|
1225
|
+
for key, value in (step.get("env") or {}).items()
|
|
1226
|
+
if key not in RUNNER_ENV
|
|
1227
|
+
}
|
|
1228
|
+
extra = {
|
|
1229
|
+
key: value
|
|
1230
|
+
for key, value in step.items()
|
|
1231
|
+
if key not in ("name", "uses", "with", "run", "working-directory", "env")
|
|
1232
|
+
}
|
|
1233
|
+
before = json.dumps([config, workflow], sort_keys=True)
|
|
1234
|
+
# Edit in place so the customer's checkout options, extra steps, and job
|
|
1235
|
+
# settings survive; only the Replay step and our old result upload change.
|
|
1236
|
+
job["steps"] = [
|
|
1237
|
+
{**replay_step(config, cli_command, env), **extra}
|
|
1238
|
+
if index == replay[0]
|
|
1239
|
+
else entry
|
|
1240
|
+
for index, entry in enumerate(steps)
|
|
1241
|
+
if not (
|
|
1242
|
+
isinstance(entry, dict)
|
|
1243
|
+
and entry.get("name") == OLD_UPLOAD_STEP
|
|
1244
|
+
and str(entry.get("uses", "")).startswith("actions/upload-artifact@")
|
|
1245
|
+
)
|
|
1246
|
+
]
|
|
1247
|
+
# The limit older setups generated; a value the customer chose stays.
|
|
1248
|
+
if job.get("timeout-minutes") == OLD_JOB_TIMEOUT:
|
|
1249
|
+
del job["timeout-minutes"]
|
|
1250
|
+
config.pop("cliCommand", None)
|
|
1251
|
+
# Setups before the SDK carried the script left a copy that nothing reads now.
|
|
1252
|
+
old_script = ".bitfab/cloudReplay.py"
|
|
1253
|
+
removable = [old_script] if within(root, old_script).exists() else []
|
|
1254
|
+
if json.dumps([config, workflow], sort_keys=True) == before:
|
|
1255
|
+
return {
|
|
1256
|
+
"files": [],
|
|
1257
|
+
"updated": False,
|
|
1258
|
+
"removable": removable,
|
|
1259
|
+
"next": "Already up to date",
|
|
1260
|
+
}
|
|
1261
|
+
outputs = {
|
|
1262
|
+
CONFIG: json.dumps(config, indent=2) + "\n",
|
|
1263
|
+
name: json.dumps(workflow, indent=2) + "\n",
|
|
1264
|
+
}
|
|
1265
|
+
for output, content in outputs.items():
|
|
1266
|
+
replace_file(within(root, output), content)
|
|
1267
|
+
return {
|
|
1268
|
+
"files": list(outputs),
|
|
1269
|
+
"updated": True,
|
|
1270
|
+
"removable": removable,
|
|
1271
|
+
"next": "Review the diff, merge the workflow to the default branch, then run --cloud-dry-run",
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
|
|
1031
1275
|
def main():
|
|
1032
1276
|
try:
|
|
1033
1277
|
if sys.argv[1:2] == ["--cloud-init"]:
|
|
@@ -1040,6 +1284,11 @@ def main():
|
|
|
1040
1284
|
result = run_cli(sys.argv[1:])
|
|
1041
1285
|
print(json.dumps(result, indent=2))
|
|
1042
1286
|
if result.get("state") == "completed" and result.get("conclusion") != "success":
|
|
1287
|
+
if result.get("testRunId"):
|
|
1288
|
+
print(
|
|
1289
|
+
f"Cloud replay stopped early ({result.get('stoppedEarly', result.get('conclusion'))}). Traces that finished are saved in test run {result['testRunId']}.",
|
|
1290
|
+
file=sys.stderr,
|
|
1291
|
+
)
|
|
1043
1292
|
return 1
|
|
1044
1293
|
if result.get("state") == "completed":
|
|
1045
1294
|
return report_errored_items(result)
|