bitfab 0.65.0 → 0.65.3
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.
- checksums.yaml +4 -4
- data/lib/bitfab/cloudReplay.py +207 -30
- data/lib/bitfab/datasets.rb +34 -14
- data/lib/bitfab/subtree.rb +9 -0
- data/lib/bitfab/traceable.rb +58 -11
- data/lib/bitfab/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1ccbb4e0e912ec35fe388c4cdb2225c4fa3c4f5e020e87c7d52f81a3358907da
|
|
4
|
+
data.tar.gz: 4e674dc9c1fb2e1c2ef6f5d5b1cf21146904a0d85989d7e41f25ce6c0b04c740
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a5f9344e7be229c578f2fb875de96aa11f49487b24775eec15955f32d18ab4552067842af6b4a8f12041f6140ee21bafbd3983630d0929e00a35236fb427e95f
|
|
7
|
+
data.tar.gz: 15bd45c45d9f6700edd7b96bbc792236441581763097307d13d4a14a76d0d8d733e5444c1586c9a24f6c990a8575e70851c37a80ff10b4f153bb84bb554e00c2
|
data/lib/bitfab/cloudReplay.py
CHANGED
|
@@ -116,6 +116,8 @@ PUSH_TRIGGER = re.compile(
|
|
|
116
116
|
r"^[ \t]*\"?(?:on\"?[ \t]*:.*\bpush\b|push\"?[ \t]*:)", re.MULTILINE
|
|
117
117
|
)
|
|
118
118
|
DISPATCH_INPUTS = ("execution_id", "request", KEY_INPUT)
|
|
119
|
+
PUSH_SUBJECT = "Bitfab replay started on push"
|
|
120
|
+
PUSH_CONDITION = f"github.event_name != 'push' || !startsWith(github.ref_name, '{PREFIX}') || startsWith(github.event.head_commit.message, '{PUSH_SUBJECT}')"
|
|
119
121
|
BRANCH_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._/-]*")
|
|
120
122
|
ENV_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
121
123
|
RESERVED_ENV_PREFIXES = ("GITHUB_", "RUNNER_", "ACTIONS_", "BITFAB_REPLAY_")
|
|
@@ -254,7 +256,8 @@ Set up once:
|
|
|
254
256
|
[--runs-on LABEL] [--secret-prefix PREFIX] [--check COMMAND]
|
|
255
257
|
Writes .github/workflows/bitfab-replay.yml, the only file cloud replay keeps
|
|
256
258
|
in the repository, and lists what is left to do. On an existing workflow it
|
|
257
|
-
adds only the lines a newer SDK needs
|
|
259
|
+
adds only the lines a newer SDK needs, such as the push trigger that lets a
|
|
260
|
+
replay run without GitHub access, and lists what it changed.
|
|
258
261
|
--cloud-update [same options] [--remove-old-key-secrets]
|
|
259
262
|
The same command as --cloud-init, for bringing an existing setup up to date.
|
|
260
263
|
It also lists BITFAB_REPLAY_KEY_* secrets an older SDK left in the
|
|
@@ -266,6 +269,16 @@ Set up once:
|
|
|
266
269
|
GitHub access comes from the GitHub CLI when it is logged in, otherwise from GH_TOKEN
|
|
267
270
|
or GITHUB_TOKEN, otherwise from the github.com credential git already stores. Without
|
|
268
271
|
the GitHub CLI, --cloud-secrets lists the secrets to create by hand and where.
|
|
272
|
+
|
|
273
|
+
Without GitHub access, or with access that cannot read or push to the repository, a
|
|
274
|
+
replay still runs when git can push the snapshot branch and the workflow also starts
|
|
275
|
+
on a push to a bitfab-replay/ branch, as one written or updated by --cloud-init does.
|
|
276
|
+
This command then warns, prints where to watch the run, and returns once the branch
|
|
277
|
+
is pushed, with no updates and exit code 0. The run uses the workflow's BITFAB_API_KEY
|
|
278
|
+
secret, and --cloud-full-output and --cloud-allow-file are refused, since only GitHub
|
|
279
|
+
access can confirm the repository is private. Delete the snapshot branch once the run
|
|
280
|
+
finishes. With access, a replay starts through the GitHub API as before, and its
|
|
281
|
+
snapshot commit asks GitHub not to start any workflow on the push.
|
|
269
282
|
"""
|
|
270
283
|
|
|
271
284
|
|
|
@@ -281,6 +294,10 @@ class DispatchRejected(ValueError):
|
|
|
281
294
|
self.record = record
|
|
282
295
|
|
|
283
296
|
|
|
297
|
+
class GithubUnreachable(ValueError):
|
|
298
|
+
pass
|
|
299
|
+
|
|
300
|
+
|
|
284
301
|
class KeepRedirect(urllib.request.HTTPRedirectHandler):
|
|
285
302
|
def redirect_request(self, *args, **kwargs):
|
|
286
303
|
return None
|
|
@@ -939,7 +956,15 @@ def sensitive(path):
|
|
|
939
956
|
)
|
|
940
957
|
|
|
941
958
|
|
|
942
|
-
def
|
|
959
|
+
def snapshot_subject(execution_id, on_push):
|
|
960
|
+
if on_push:
|
|
961
|
+
return f"{PUSH_SUBJECT} {execution_id}"
|
|
962
|
+
return f"Bitfab replay {execution_id} [skip actions]"
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def snapshot(
|
|
966
|
+
root, workflow, files, request, *, preview=False, allowed=(), on_push=False
|
|
967
|
+
):
|
|
943
968
|
if git(root, "ls-files", "-u"):
|
|
944
969
|
raise ValueError("Resolve merge conflicts before snapshotting")
|
|
945
970
|
head = git(root, "rev-parse", "HEAD")
|
|
@@ -1014,7 +1039,7 @@ def snapshot(root, workflow, files, request, *, preview=False, allowed=()):
|
|
|
1014
1039
|
tree,
|
|
1015
1040
|
"-p",
|
|
1016
1041
|
head,
|
|
1017
|
-
input=f"
|
|
1042
|
+
input=f"{snapshot_subject(request['id'], on_push)}\n\n{json.dumps(request)}\n",
|
|
1018
1043
|
)
|
|
1019
1044
|
return {**summary, "sha": sha}
|
|
1020
1045
|
|
|
@@ -1050,9 +1075,8 @@ def execution_lock(directory, execution_id):
|
|
|
1050
1075
|
def find_run(record):
|
|
1051
1076
|
if record.get("snapshot") is False:
|
|
1052
1077
|
return find_branch_run(record)
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
)
|
|
1078
|
+
event = "push" if record.get("startedOnPush") else "workflow_dispatch"
|
|
1079
|
+
query = urlencode({"event": event, "branch": record["branch"], "per_page": 100})
|
|
1056
1080
|
runs = api(
|
|
1057
1081
|
record["repository"], f"actions/workflows/{record['workflow']}/runs?{query}"
|
|
1058
1082
|
)
|
|
@@ -1274,15 +1298,19 @@ def http_status(error):
|
|
|
1274
1298
|
|
|
1275
1299
|
|
|
1276
1300
|
def preflight(repo, workflow, *, pushes=True, full_output=False, allows_files=False):
|
|
1277
|
-
|
|
1301
|
+
try:
|
|
1302
|
+
access = github_access()
|
|
1303
|
+
except ValueError as error:
|
|
1304
|
+
raise GithubUnreachable(str(error)) from error
|
|
1278
1305
|
try:
|
|
1279
1306
|
details = api(repo, "")
|
|
1280
1307
|
except RuntimeError as error:
|
|
1281
|
-
|
|
1308
|
+
refused = getattr(error, "http_status", None) in (401, 403, 404)
|
|
1309
|
+
raise (GithubUnreachable if refused else ValueError)(
|
|
1282
1310
|
f"GitHub access from {access['source']} cannot read {repo}{http_status(error)}; use an account or token with write access to it"
|
|
1283
1311
|
) from error
|
|
1284
1312
|
if pushes and (details or {}).get("permissions", {}).get("push") is False:
|
|
1285
|
-
raise
|
|
1313
|
+
raise GithubUnreachable(
|
|
1286
1314
|
f"GitHub access from {access['source']} cannot push to {repo}; use an account or token with write access to it"
|
|
1287
1315
|
)
|
|
1288
1316
|
if full_output and (details or {}).get("private") is False:
|
|
@@ -1344,6 +1372,12 @@ def submit(root, repo, workflow, parsed, path):
|
|
|
1344
1372
|
record = json.loads(path.read_text())
|
|
1345
1373
|
if record["request"] != request or record["repository"].lower() != repo.lower():
|
|
1346
1374
|
raise ValueError("Execution ID already belongs to a different request")
|
|
1375
|
+
if record.get("startedOnPush") and record["state"] != "prepared":
|
|
1376
|
+
try:
|
|
1377
|
+
github_access()
|
|
1378
|
+
except ValueError as error:
|
|
1379
|
+
record["unfollowed"] = str(error)
|
|
1380
|
+
return record
|
|
1347
1381
|
status(record, fetch_result=False)
|
|
1348
1382
|
if record["state"] == "prepared":
|
|
1349
1383
|
raise ValueError(
|
|
@@ -1351,17 +1385,24 @@ def submit(root, repo, workflow, parsed, path):
|
|
|
1351
1385
|
)
|
|
1352
1386
|
refuse_rejected(record)
|
|
1353
1387
|
return record
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1388
|
+
unfollowed = None
|
|
1389
|
+
try:
|
|
1390
|
+
preflight(
|
|
1391
|
+
repo,
|
|
1392
|
+
workflow,
|
|
1393
|
+
full_output=request.get("fullOutput") is True,
|
|
1394
|
+
allows_files=bool(allowed),
|
|
1395
|
+
)
|
|
1396
|
+
except GithubUnreachable as error:
|
|
1397
|
+
unfollowed = str(error)
|
|
1398
|
+
refuse_unfollowed(root, repo, workflow, request, allowed, unfollowed)
|
|
1360
1399
|
if dry_run(request["args"]):
|
|
1361
1400
|
progress(
|
|
1362
1401
|
"--dry-run dispatches a GitHub run that checks secrets and resolves the traces without replaying; it receives the workflow's secrets"
|
|
1363
1402
|
)
|
|
1364
|
-
source = snapshot(
|
|
1403
|
+
source = snapshot(
|
|
1404
|
+
root, workflow, files, request, allowed=allowed, on_push=bool(unfollowed)
|
|
1405
|
+
)
|
|
1365
1406
|
describe_snapshot(source)
|
|
1366
1407
|
record = {
|
|
1367
1408
|
"id": execution_id,
|
|
@@ -1370,6 +1411,7 @@ def submit(root, repo, workflow, parsed, path):
|
|
|
1370
1411
|
"branch": PREFIX + execution_id,
|
|
1371
1412
|
"request": request,
|
|
1372
1413
|
"state": "prepared",
|
|
1414
|
+
**({"startedOnPush": True} if unfollowed else {}),
|
|
1373
1415
|
**source,
|
|
1374
1416
|
}
|
|
1375
1417
|
save(path, record)
|
|
@@ -1389,6 +1431,9 @@ def submit(root, repo, workflow, parsed, path):
|
|
|
1389
1431
|
) from error
|
|
1390
1432
|
record["state"] = "dispatch_unknown"
|
|
1391
1433
|
save(path, record)
|
|
1434
|
+
if unfollowed:
|
|
1435
|
+
record["unfollowed"] = unfollowed
|
|
1436
|
+
return record
|
|
1392
1437
|
text = within(root, f"{WORKFLOW_DIRECTORY}/{workflow}").read_text()
|
|
1393
1438
|
declared = declared_inputs(text)
|
|
1394
1439
|
key_inputs = issue_key_token(root, record, text)
|
|
@@ -1400,6 +1445,62 @@ def submit(root, repo, workflow, parsed, path):
|
|
|
1400
1445
|
)
|
|
1401
1446
|
|
|
1402
1447
|
|
|
1448
|
+
def push_trigger(lines):
|
|
1449
|
+
for index, line in enumerate(lines):
|
|
1450
|
+
if re.fullmatch(r'"?on"?[ \t]*:[ \t]*(#.*)?', line):
|
|
1451
|
+
end = block_end(lines, index, 0)
|
|
1452
|
+
step = child_indent(lines, index, end, " ")
|
|
1453
|
+
row = next(
|
|
1454
|
+
(
|
|
1455
|
+
row
|
|
1456
|
+
for row in range(index + 1, end)
|
|
1457
|
+
if re.fullmatch(rf'{step}"?push"?[ \t]*:.*', lines[row])
|
|
1458
|
+
),
|
|
1459
|
+
None,
|
|
1460
|
+
)
|
|
1461
|
+
if row is None:
|
|
1462
|
+
return None
|
|
1463
|
+
return lines[row : block_end(lines, row, len(step))]
|
|
1464
|
+
if re.match(r'"?on"?[ \t]*:', line):
|
|
1465
|
+
return [line] if re.search(r"\bpush\b", line) else None
|
|
1466
|
+
return None
|
|
1467
|
+
|
|
1468
|
+
|
|
1469
|
+
def starts_on_push(text):
|
|
1470
|
+
trigger = push_trigger(text.splitlines()) or []
|
|
1471
|
+
return any(PREFIX in line for line in trigger) and not any(
|
|
1472
|
+
"branches-ignore" in line for line in trigger
|
|
1473
|
+
)
|
|
1474
|
+
|
|
1475
|
+
|
|
1476
|
+
def refuse_unfollowed(root, repo, workflow, request, allowed, reason):
|
|
1477
|
+
path = f"{WORKFLOW_DIRECTORY}/{workflow}"
|
|
1478
|
+
if not starts_on_push(within(root, path).read_text()):
|
|
1479
|
+
raise ValueError(
|
|
1480
|
+
f"{reason}. Without it a replay can still run, with no updates here, once {path} also starts on a push to a {PREFIX} branch: run bitfab-replay --cloud-update and commit the workflow it updates"
|
|
1481
|
+
)
|
|
1482
|
+
if request.get("fullOutput") is True:
|
|
1483
|
+
raise ValueError(
|
|
1484
|
+
f"--cloud-full-output writes every trace's input and output into the Actions job log, so it needs GitHub access that confirms {repo} is private: {reason}"
|
|
1485
|
+
)
|
|
1486
|
+
if allowed:
|
|
1487
|
+
raise ValueError(
|
|
1488
|
+
f"{ALLOW_FLAG} uploads a credential-like file to a snapshot branch, so it needs GitHub access that confirms {repo} is private: {reason}"
|
|
1489
|
+
)
|
|
1490
|
+
|
|
1491
|
+
|
|
1492
|
+
def report_unfollowed(record, reason):
|
|
1493
|
+
progress(
|
|
1494
|
+
f"Warning: the replay started on GitHub, but this command gets no updates on it: {reason}"
|
|
1495
|
+
)
|
|
1496
|
+
progress(
|
|
1497
|
+
f"It runs with the workflow's BITFAB_API_KEY secret, since replaying as you needs GitHub access. Watch it at https://github.com/{record['repository']}/actions/workflows/{record['workflow']}; its experiment appears in Bitfab when it finishes"
|
|
1498
|
+
)
|
|
1499
|
+
progress(
|
|
1500
|
+
f"Once it finishes, delete its snapshot branch with git push origin --delete {record['branch']}, or with --cloud-cleanup {record['id']} from a machine with GitHub access"
|
|
1501
|
+
)
|
|
1502
|
+
|
|
1503
|
+
|
|
1403
1504
|
def local_client(root, request):
|
|
1404
1505
|
try:
|
|
1405
1506
|
output = command(
|
|
@@ -1736,6 +1837,11 @@ def run_cli(argv):
|
|
|
1736
1837
|
raise
|
|
1737
1838
|
if record.get("preview"):
|
|
1738
1839
|
return record, None
|
|
1840
|
+
reason = record.pop("unfollowed", None)
|
|
1841
|
+
if reason:
|
|
1842
|
+
report_unfollowed(record, reason)
|
|
1843
|
+
save(path, record)
|
|
1844
|
+
return record, None
|
|
1739
1845
|
else:
|
|
1740
1846
|
if not path.exists():
|
|
1741
1847
|
raise ValueError(
|
|
@@ -2791,7 +2897,7 @@ def push_triggers(root, workflow):
|
|
|
2791
2897
|
|
|
2792
2898
|
|
|
2793
2899
|
def workflow_document(steps, run, directory, env, *, runs_on, environment):
|
|
2794
|
-
job = {"runs-on": runs_on}
|
|
2900
|
+
job = {"if": PUSH_CONDITION, "runs-on": runs_on}
|
|
2795
2901
|
if environment:
|
|
2796
2902
|
job["environment"] = environment
|
|
2797
2903
|
job["steps"] = [
|
|
@@ -2816,7 +2922,8 @@ def workflow_document(steps, run, directory, env, *, runs_on, environment):
|
|
|
2816
2922
|
"request": {"type": "string", "required": False},
|
|
2817
2923
|
KEY_INPUT: {"type": "string", "required": False},
|
|
2818
2924
|
}
|
|
2819
|
-
}
|
|
2925
|
+
},
|
|
2926
|
+
"push": {"branches": [PREFIX + "**"]},
|
|
2820
2927
|
},
|
|
2821
2928
|
"permissions": {"contents": "read", "id-token": "write"},
|
|
2822
2929
|
"jobs": {"replay": job},
|
|
@@ -2830,10 +2937,18 @@ def indentation(line):
|
|
|
2830
2937
|
def block_end(lines, start, indent):
|
|
2831
2938
|
end = start + 1
|
|
2832
2939
|
while end < len(lines) and (
|
|
2833
|
-
not lines[end].strip()
|
|
2940
|
+
not lines[end].strip()
|
|
2941
|
+
or lines[end].lstrip().startswith("#")
|
|
2942
|
+
or indentation(lines[end]) > indent
|
|
2834
2943
|
):
|
|
2835
2944
|
end += 1
|
|
2836
|
-
while end > start + 1 and
|
|
2945
|
+
while end > start + 1 and (
|
|
2946
|
+
not lines[end - 1].strip()
|
|
2947
|
+
or (
|
|
2948
|
+
lines[end - 1].lstrip().startswith("#")
|
|
2949
|
+
and indentation(lines[end - 1]) <= indent
|
|
2950
|
+
)
|
|
2951
|
+
):
|
|
2837
2952
|
end -= 1
|
|
2838
2953
|
return end
|
|
2839
2954
|
|
|
@@ -2938,19 +3053,67 @@ def add_id_token_permission(lines):
|
|
|
2938
3053
|
if not re.fullmatch(r'"?permissions"?[ \t]*:[ \t]*(#.*)?', line):
|
|
2939
3054
|
continue
|
|
2940
3055
|
end = block_end(lines, index, 0)
|
|
2941
|
-
|
|
2942
|
-
(
|
|
2943
|
-
lines[row][: indentation(lines[row])]
|
|
2944
|
-
for row in range(index + 1, end)
|
|
2945
|
-
if lines[row].strip()
|
|
2946
|
-
),
|
|
2947
|
-
" ",
|
|
2948
|
-
)
|
|
2949
|
-
lines.insert(end, f"{child}id-token: write")
|
|
3056
|
+
lines.insert(end, f"{child_indent(lines, index, end, ' ')}id-token: write")
|
|
2950
3057
|
return True
|
|
2951
3058
|
return False
|
|
2952
3059
|
|
|
2953
3060
|
|
|
3061
|
+
def holds_yaml(line):
|
|
3062
|
+
return bool(line.strip()) and not line.lstrip().startswith("#")
|
|
3063
|
+
|
|
3064
|
+
|
|
3065
|
+
def child_indent(lines, index, end, fallback):
|
|
3066
|
+
return next(
|
|
3067
|
+
(
|
|
3068
|
+
lines[row][: indentation(lines[row])]
|
|
3069
|
+
for row in range(index + 1, end)
|
|
3070
|
+
if holds_yaml(lines[row])
|
|
3071
|
+
),
|
|
3072
|
+
fallback,
|
|
3073
|
+
)
|
|
3074
|
+
|
|
3075
|
+
|
|
3076
|
+
def add_push_trigger(lines):
|
|
3077
|
+
for index, line in enumerate(lines):
|
|
3078
|
+
if not re.fullmatch(r'"?on"?[ \t]*:[ \t]*(#.*)?', line):
|
|
3079
|
+
continue
|
|
3080
|
+
end = block_end(lines, index, 0)
|
|
3081
|
+
step = child_indent(lines, index, end, " ")
|
|
3082
|
+
lines[end:end] = [
|
|
3083
|
+
f"{step}push:",
|
|
3084
|
+
f"{step * 2}branches:",
|
|
3085
|
+
f"{step * 3}- {PREFIX}**",
|
|
3086
|
+
]
|
|
3087
|
+
return True
|
|
3088
|
+
return False
|
|
3089
|
+
|
|
3090
|
+
|
|
3091
|
+
def add_push_condition(lines):
|
|
3092
|
+
jobs = next(
|
|
3093
|
+
(
|
|
3094
|
+
index
|
|
3095
|
+
for index, line in enumerate(lines)
|
|
3096
|
+
if re.fullmatch(r'"?jobs"?[ \t]*:[ \t]*(#.*)?', line)
|
|
3097
|
+
),
|
|
3098
|
+
None,
|
|
3099
|
+
)
|
|
3100
|
+
if jobs is None:
|
|
3101
|
+
return False
|
|
3102
|
+
end = block_end(lines, jobs, 0)
|
|
3103
|
+
job = next((row for row in range(jobs + 1, end) if holds_yaml(lines[row])), None)
|
|
3104
|
+
if job is None or not lines[job].rstrip().endswith(":"):
|
|
3105
|
+
return False
|
|
3106
|
+
job_end = block_end(lines, job, indentation(lines[job]))
|
|
3107
|
+
step = child_indent(lines, job, job_end, " " * (indentation(lines[job]) + 2))
|
|
3108
|
+
if any(
|
|
3109
|
+
re.fullmatch(rf'{step}"?if"?[ \t]*:.*', lines[row])
|
|
3110
|
+
for row in range(job + 1, job_end)
|
|
3111
|
+
):
|
|
3112
|
+
return False
|
|
3113
|
+
lines.insert(job + 1, f"{step}if: {json.dumps(PUSH_CONDITION)}")
|
|
3114
|
+
return True
|
|
3115
|
+
|
|
3116
|
+
|
|
2954
3117
|
def secret_scope(environment):
|
|
2955
3118
|
return f"environments/{environment}/" if environment else "actions/"
|
|
2956
3119
|
|
|
@@ -3041,6 +3204,20 @@ def update_workflow(root, path, *, remove_old_key_secrets=False):
|
|
|
3041
3204
|
by_hand.append(
|
|
3042
3205
|
f"{name} (type: string, required: false) under on.workflow_dispatch.inputs"
|
|
3043
3206
|
)
|
|
3207
|
+
if not starts_on_push(text):
|
|
3208
|
+
if push_trigger(lines) is None and add_push_trigger(lines):
|
|
3209
|
+
updated.append(
|
|
3210
|
+
f"Started the workflow on a push to a {PREFIX} branch too, so a replay runs without GitHub access, with no updates on it"
|
|
3211
|
+
)
|
|
3212
|
+
else:
|
|
3213
|
+
by_hand.append(f"{PREFIX}** in on.push.branches")
|
|
3214
|
+
if push_trigger(lines) is not None and PUSH_SUBJECT not in text:
|
|
3215
|
+
if add_push_condition(lines):
|
|
3216
|
+
updated.append(
|
|
3217
|
+
"Skipped the push run of a replay started through the GitHub API, so each replay runs once"
|
|
3218
|
+
)
|
|
3219
|
+
else:
|
|
3220
|
+
by_hand.append(f"if: {json.dumps(PUSH_CONDITION)} on the replay job")
|
|
3044
3221
|
if not re.search(r'^"?run-name"?[ \t]*:', text, re.MULTILINE):
|
|
3045
3222
|
add_run_name(lines)
|
|
3046
3223
|
updated.append("Named each run after its execution")
|
|
@@ -3095,7 +3272,7 @@ def update_workflow(root, path, *, remove_old_key_secrets=False):
|
|
|
3095
3272
|
def initialize(argv, name="--cloud-init"):
|
|
3096
3273
|
parser = argparse.ArgumentParser(
|
|
3097
3274
|
prog=f"bitfab-replay {name}",
|
|
3098
|
-
description="Write .github/workflows/bitfab-replay.yml, the only file cloud replay keeps in the repository, and list what is left to do. It detects the runtime, package manager, install command, and replay command from the directory you run it in. On an existing workflow it only adds what a newer SDK needs (the execution_id and key_id inputs, the run name, and the id-token: write permission), removes the BITFAB_REPLAY_BITFAB_API_KEY line older SDKs wrote, lists what it changed, and lists the BITFAB_REPLAY_KEY_* secrets older SDKs left in the repository; edit it directly for anything else.",
|
|
3275
|
+
description="Write .github/workflows/bitfab-replay.yml, the only file cloud replay keeps in the repository, and list what is left to do. It detects the runtime, package manager, install command, and replay command from the directory you run it in. On an existing workflow it only adds what a newer SDK needs (the execution_id and key_id inputs, the push trigger and the condition that skips a duplicate push run, the run name, and the id-token: write permission), removes the BITFAB_REPLAY_BITFAB_API_KEY line older SDKs wrote, lists what it changed, and lists the BITFAB_REPLAY_KEY_* secrets older SDKs left in the repository; edit it directly for anything else.",
|
|
3099
3276
|
)
|
|
3100
3277
|
parser.add_argument(
|
|
3101
3278
|
"--secret",
|
data/lib/bitfab/datasets.rb
CHANGED
|
@@ -17,6 +17,9 @@ module Bitfab
|
|
|
17
17
|
# trace function. Experiments replay against it and its graders score its
|
|
18
18
|
# members. Every method returns the parsed JSON response as a Hash with
|
|
19
19
|
# string keys matching the HTTP API ("traceCount", "addedTraceIds", ...).
|
|
20
|
+
# Every dataset summary carries "golden", true when the dataset is golden
|
|
21
|
+
# for the whole organization and listed first in the Bitfab app, and false
|
|
22
|
+
# otherwise, including when an older server leaves the field out.
|
|
20
23
|
class Datasets
|
|
21
24
|
DEFAULT_RERUN_TIMEOUT_SECONDS = 90
|
|
22
25
|
RERUN_POLL_INTERVAL_SECONDS = 1.0
|
|
@@ -29,11 +32,14 @@ module Bitfab
|
|
|
29
32
|
|
|
30
33
|
# Create a dataset, or update the one already named this way under the
|
|
31
34
|
# same trace function. The result's "created" reports which happened. A
|
|
32
|
-
# nil description leaves an existing description untouched.
|
|
33
|
-
|
|
35
|
+
# nil description leaves an existing description untouched. golden: true
|
|
36
|
+
# marks the dataset as golden for the whole organization so the Bitfab app
|
|
37
|
+
# lists it first, false unmarks it, and nil leaves it as it is.
|
|
38
|
+
def save(trace_function_key:, name:, description: nil, golden: nil)
|
|
34
39
|
payload = {"traceFunctionKey" => trace_function_key, "name" => name}
|
|
35
40
|
payload["description"] = description unless description.nil?
|
|
36
|
-
|
|
41
|
+
payload["golden"] = golden unless golden.nil?
|
|
42
|
+
with_golden_default(@http_client.request("/api/sdk/datasets", payload))
|
|
37
43
|
end
|
|
38
44
|
|
|
39
45
|
# List datasets, scoped to one trace function when given and
|
|
@@ -44,7 +50,7 @@ module Bitfab
|
|
|
44
50
|
datasets = []
|
|
45
51
|
loop do
|
|
46
52
|
result = @http_client.get("/api/sdk/datasets?#{URI.encode_www_form(query)}")
|
|
47
|
-
datasets.concat(result["datasets"])
|
|
53
|
+
datasets.concat(result["datasets"].map { |summary| summary_with_golden_default(summary) })
|
|
48
54
|
cursor = result["nextCursor"]
|
|
49
55
|
return datasets if cursor.nil?
|
|
50
56
|
|
|
@@ -55,22 +61,24 @@ module Bitfab
|
|
|
55
61
|
# Fetch one dataset by id. A dataset outside this organization raises
|
|
56
62
|
# Net::HTTPError with a 404.
|
|
57
63
|
def get(dataset_id)
|
|
58
|
-
@http_client.get(dataset_path(dataset_id))["dataset"]
|
|
64
|
+
summary_with_golden_default(@http_client.get(dataset_path(dataset_id))["dataset"])
|
|
59
65
|
end
|
|
60
66
|
|
|
61
67
|
# Rename the dataset or change its description, and return the updated
|
|
62
68
|
# dataset. It keeps its id, traces, and graders. A nil argument leaves that
|
|
63
|
-
# field untouched, and an empty description clears it.
|
|
64
|
-
#
|
|
69
|
+
# field untouched, and an empty description clears it. golden: true marks
|
|
70
|
+
# the dataset as golden for the whole organization and false unmarks it.
|
|
71
|
+
# Raises ArgumentError when name, description, and golden are all nil. A blank name, or one another dataset under the same
|
|
65
72
|
# trace function already uses, raises Net::HTTPError with a 400. A dataset
|
|
66
73
|
# outside this organization raises Net::HTTPError with a 404.
|
|
67
|
-
def update(dataset_id, name: nil, description: nil)
|
|
68
|
-
raise ArgumentError, "Provide name and/or
|
|
74
|
+
def update(dataset_id, name: nil, description: nil, golden: nil)
|
|
75
|
+
raise ArgumentError, "Provide name, description, and/or golden to update." if name.nil? && description.nil? && golden.nil?
|
|
69
76
|
|
|
70
77
|
payload = {}
|
|
71
78
|
payload["name"] = name unless name.nil?
|
|
72
79
|
payload["description"] = description unless description.nil?
|
|
73
|
-
|
|
80
|
+
payload["golden"] = golden unless golden.nil?
|
|
81
|
+
summary_with_golden_default(@http_client.request(dataset_path(dataset_id), payload, method: "PATCH")["dataset"])
|
|
74
82
|
end
|
|
75
83
|
|
|
76
84
|
# The ids of every trace in the dataset, the same membership a replay with
|
|
@@ -92,24 +100,24 @@ module Bitfab
|
|
|
92
100
|
# organization or under another trace function are reported in
|
|
93
101
|
# "skippedTraceIds" rather than failing the call.
|
|
94
102
|
def add_traces(dataset_id, trace_ids)
|
|
95
|
-
@http_client.request(dataset_path(dataset_id, "/traces"), {"traceIds" => trace_ids})
|
|
103
|
+
with_golden_default(@http_client.request(dataset_path(dataset_id, "/traces"), {"traceIds" => trace_ids}))
|
|
96
104
|
end
|
|
97
105
|
|
|
98
106
|
# Remove traces from the dataset. The traces themselves are never deleted.
|
|
99
107
|
def remove_traces(dataset_id, trace_ids)
|
|
100
|
-
@http_client.request(dataset_path(dataset_id, "/removeTraces"), {"traceIds" => trace_ids})
|
|
108
|
+
with_golden_default(@http_client.request(dataset_path(dataset_id, "/removeTraces"), {"traceIds" => trace_ids}))
|
|
101
109
|
end
|
|
102
110
|
|
|
103
111
|
# Assign graders to the dataset (1 to 100 ids per call). Graders outside
|
|
104
112
|
# the organization or under another trace function are reported in
|
|
105
113
|
# "skippedGraderIds" rather than failing the call.
|
|
106
114
|
def add_graders(dataset_id, grader_ids)
|
|
107
|
-
@http_client.request(dataset_path(dataset_id, "/graders"), {"graderIds" => grader_ids})
|
|
115
|
+
with_golden_default(@http_client.request(dataset_path(dataset_id, "/graders"), {"graderIds" => grader_ids}))
|
|
108
116
|
end
|
|
109
117
|
|
|
110
118
|
# Unassign graders from the dataset.
|
|
111
119
|
def remove_graders(dataset_id, grader_ids)
|
|
112
|
-
@http_client.request(dataset_path(dataset_id, "/removeGraders"), {"graderIds" => grader_ids})
|
|
120
|
+
with_golden_default(@http_client.request(dataset_path(dataset_id, "/removeGraders"), {"graderIds" => grader_ids}))
|
|
113
121
|
end
|
|
114
122
|
|
|
115
123
|
# Re-run graders over every trace in the dataset. Defaults to every
|
|
@@ -140,6 +148,18 @@ module Bitfab
|
|
|
140
148
|
|
|
141
149
|
private
|
|
142
150
|
|
|
151
|
+
def with_golden_default(result)
|
|
152
|
+
return result unless result["dataset"].is_a?(Hash)
|
|
153
|
+
|
|
154
|
+
result.merge("dataset" => summary_with_golden_default(result["dataset"]))
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def summary_with_golden_default(summary)
|
|
158
|
+
return summary unless summary.is_a?(Hash)
|
|
159
|
+
|
|
160
|
+
{"golden" => false}.merge(summary)
|
|
161
|
+
end
|
|
162
|
+
|
|
143
163
|
def settle_grader_rerun(dataset_id, run, timeout)
|
|
144
164
|
case run["status"]
|
|
145
165
|
when "completed"
|
data/lib/bitfab/subtree.rb
CHANGED
|
@@ -796,6 +796,8 @@ module Bitfab
|
|
|
796
796
|
end
|
|
797
797
|
|
|
798
798
|
def skip_boundary_call?(event)
|
|
799
|
+
return false if sdk_frame?(event.path)
|
|
800
|
+
|
|
799
801
|
boundary = @stacks[Fiber.current.object_id]&.last
|
|
800
802
|
if boundary.is_a?(Boundary) && boundary.skip_next_call &&
|
|
801
803
|
boundary.receiver.equal?(event.self) && boundary.method_name == called_method_id(event)
|
|
@@ -819,6 +821,13 @@ module Bitfab
|
|
|
819
821
|
true
|
|
820
822
|
end
|
|
821
823
|
|
|
824
|
+
def sdk_frame?(path)
|
|
825
|
+
@sdk_frames ||= {}
|
|
826
|
+
@sdk_frames.fetch(path) do
|
|
827
|
+
@sdk_frames[path] = Subtree.realpath(path)&.start_with?(SDK_PREFIX) || false
|
|
828
|
+
end
|
|
829
|
+
end
|
|
830
|
+
|
|
822
831
|
def passthrough_wrapper?(parameters)
|
|
823
832
|
kinds = parameters.map(&:first)
|
|
824
833
|
kinds.any? { |kind| kind == :rest || kind == :keyrest } &&
|
data/lib/bitfab/traceable.rb
CHANGED
|
@@ -108,7 +108,7 @@ module Bitfab
|
|
|
108
108
|
client: target_client, trace_function_key:, config: capture_config,
|
|
109
109
|
max_depth:, max_captured_subtree_spans:, include_wrappers:, mock_on_replay_default:,
|
|
110
110
|
root_receiver: self, root_method_name: method_name
|
|
111
|
-
) { super(*args, **kwargs, &block) }
|
|
111
|
+
) { Traceable.send(:as_root_call, self, method_name) { super(*args, **kwargs, &block) } }
|
|
112
112
|
end
|
|
113
113
|
call = Subtree::ConfiguredCall.new(receiver: self, method_name:, name: span_name,
|
|
114
114
|
type:, args:, kwargs:, nested_trace_key: trace_function_key)
|
|
@@ -122,12 +122,14 @@ module Bitfab
|
|
|
122
122
|
client: target_client, trace_function_key:, config: capture_config,
|
|
123
123
|
max_depth:, max_captured_subtree_spans:, include_wrappers:, mock_on_replay_default:,
|
|
124
124
|
root_receiver: self, root_method_name: method_name
|
|
125
|
-
) { super(*args, **kwargs, &block) }
|
|
125
|
+
) { Traceable.send(:as_root_call, self, method_name) { super(*args, **kwargs, &block) } }
|
|
126
126
|
end
|
|
127
127
|
end
|
|
128
128
|
end
|
|
129
129
|
wrapper.instance_variable_set(:@bitfab_span_method, method_name_str)
|
|
130
130
|
wrapper.instance_variable_set(:@bitfab_span_key, trace_function_key)
|
|
131
|
+
wrapper.instance_variable_set(:@bitfab_trace_method, method_name_str)
|
|
132
|
+
wrapper.instance_variable_set(:@bitfab_trace_client, bound_client)
|
|
131
133
|
preserve_visibility(klass, wrapper, method_name)
|
|
132
134
|
klass.prepend(wrapper)
|
|
133
135
|
end
|
|
@@ -142,9 +144,12 @@ module Bitfab
|
|
|
142
144
|
bound_client = client
|
|
143
145
|
wrapper = Module.new do
|
|
144
146
|
define_method(method_name) do |*args, **kwargs, &block|
|
|
147
|
+
next super(*args, **kwargs, &block) if Traceable.send(:own_span_call?, self, method_name)
|
|
148
|
+
|
|
145
149
|
Subtree.assert_surface!(:trace)
|
|
146
150
|
session = Subtree.current_session
|
|
147
|
-
if !session || (bound_client && !session.client.equal?(bound_client))
|
|
151
|
+
if !session || (bound_client && !session.client.equal?(bound_client)) ||
|
|
152
|
+
Traceable.send(:root_handles_call?, self, method_name, wrapper)
|
|
148
153
|
next super(*args, **kwargs, &block)
|
|
149
154
|
end
|
|
150
155
|
unless capture
|
|
@@ -176,6 +181,49 @@ module Bitfab
|
|
|
176
181
|
klass.prepend(wrapper)
|
|
177
182
|
end
|
|
178
183
|
|
|
184
|
+
ROOT_CALL_KEY = :bitfab_root_call
|
|
185
|
+
|
|
186
|
+
def self.as_root_call(receiver, method_name)
|
|
187
|
+
previous = Thread.current[ROOT_CALL_KEY]
|
|
188
|
+
Thread.current[ROOT_CALL_KEY] = [receiver, method_name.to_sym]
|
|
189
|
+
yield
|
|
190
|
+
ensure
|
|
191
|
+
Thread.current[ROOT_CALL_KEY] = previous
|
|
192
|
+
end
|
|
193
|
+
private_class_method :as_root_call
|
|
194
|
+
|
|
195
|
+
def self.root_handles_call?(receiver, method_name, node_wrapper)
|
|
196
|
+
marker = Thread.current[ROOT_CALL_KEY]
|
|
197
|
+
if marker && marker[0].equal?(receiver) && marker[1] == method_name.to_sym
|
|
198
|
+
Thread.current[ROOT_CALL_KEY] = nil
|
|
199
|
+
return true
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
trace_wrapper = inner_trace_wrapper(receiver, method_name, node_wrapper)
|
|
203
|
+
return false unless trace_wrapper
|
|
204
|
+
|
|
205
|
+
client = trace_wrapper.instance_variable_get(:@bitfab_trace_client) || Bitfab.client_or_nil
|
|
206
|
+
(client&.send(:should_record?) && !Subtree.nested_root_suppressed?) ? true : false
|
|
207
|
+
end
|
|
208
|
+
private_class_method :root_handles_call?
|
|
209
|
+
|
|
210
|
+
def self.own_span_call?(receiver, method_name)
|
|
211
|
+
span = SpanContext.current
|
|
212
|
+
return false unless span && span[:surface] == :span
|
|
213
|
+
|
|
214
|
+
span[:explicit_span_receiver].equal?(receiver) && span[:explicit_span_method_name] == method_name
|
|
215
|
+
end
|
|
216
|
+
private_class_method :own_span_call?
|
|
217
|
+
|
|
218
|
+
def self.inner_trace_wrapper(receiver, method_name, node_wrapper)
|
|
219
|
+
ancestors = receiver.is_a?(Module) ? receiver.singleton_class.ancestors : receiver.class.ancestors
|
|
220
|
+
name = method_name.to_s
|
|
221
|
+
ancestors.drop_while { |mod| !mod.equal?(node_wrapper) }.find do |mod|
|
|
222
|
+
mod.instance_variable_defined?(:@bitfab_trace_method) && mod.instance_variable_get(:@bitfab_trace_method) == name
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
private_class_method :inner_trace_wrapper
|
|
226
|
+
|
|
179
227
|
def self.source_of(klass, method_name)
|
|
180
228
|
path, line = klass.instance_method(method_name).source_location || [nil, nil]
|
|
181
229
|
[path, line, method_name]
|
|
@@ -345,15 +393,14 @@ module Bitfab
|
|
|
345
393
|
|
|
346
394
|
def method_added(method_name)
|
|
347
395
|
super
|
|
396
|
+
if defined?(@_bitfab_pending_nodes) && @_bitfab_pending_nodes&.key?(method_name)
|
|
397
|
+
_bitfab_node_method(method_name, **@_bitfab_pending_nodes.delete(method_name))
|
|
398
|
+
end
|
|
348
399
|
if defined?(@_bitfab_pending_spans) && @_bitfab_pending_spans&.key?(method_name)
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
_bitfab_trace_method(method_name, **config)
|
|
354
|
-
elsif defined?(@_bitfab_pending_nodes) && @_bitfab_pending_nodes&.key?(method_name)
|
|
355
|
-
config = @_bitfab_pending_nodes.delete(method_name)
|
|
356
|
-
_bitfab_node_method(method_name, **config)
|
|
400
|
+
_bitfab_wrap_method(method_name, **@_bitfab_pending_spans.delete(method_name))
|
|
401
|
+
end
|
|
402
|
+
if defined?(@_bitfab_pending_traces) && @_bitfab_pending_traces&.key?(method_name)
|
|
403
|
+
_bitfab_trace_method(method_name, **@_bitfab_pending_traces.delete(method_name))
|
|
357
404
|
end
|
|
358
405
|
end
|
|
359
406
|
|
data/lib/bitfab/version.rb
CHANGED