bitfab 0.64.18 → 0.64.19
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 +339 -93
- data/lib/bitfab/replay_cli.rb +3 -2
- 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: a4949c6f7ebe457c48b386ae3243b7b5f4735f9fb0e8c2248db559328ca2d532
|
|
4
|
+
data.tar.gz: cd72bfba1753c4c2a4baee978f2fd401fe9cecfccdf56b90dd01a70a49aea85d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 525e777fd4ae7517aa2bec6efabb08afd0d944efb17a270952ec8bd762e04c5f78dba6070fe0e2b33f7174ec0c198c5c55bf3c20a2e55511f7b0c9ae2232745e
|
|
7
|
+
data.tar.gz: d68cbd2fd4dbc0c84e50c773f3e99b3e7edc5bf7e7cd481734db313e62c63169fe2332c1dbbcaedf5172760aa840dd7db8aa100cdb6fe6c46b8034569e4b35eb
|
data/lib/bitfab/cloudReplay.py
CHANGED
|
@@ -40,9 +40,19 @@ API_KEY_ENV = "BITFAB_REPLAY_BITFAB_API_KEY"
|
|
|
40
40
|
PRINT_API_KEY_FLAG = "--print-api-key"
|
|
41
41
|
API_KEY_LINE = "@@bitfab:api-key "
|
|
42
42
|
KEY_INPUT = "key_id"
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
KEY_TOKEN_PREFIX = "bfcr_"
|
|
44
|
+
LEFTOVER_KEY_SECRET_PREFIX = "BITFAB_REPLAY_KEY_"
|
|
45
|
+
SECRETS_PER_PAGE = 100
|
|
46
|
+
LEGACY_KEY_SECRET_REFERENCE = re.compile(
|
|
47
|
+
r"^[ \t]*\"?"
|
|
48
|
+
+ API_KEY_ENV
|
|
49
|
+
+ r"\"?[ \t]*:[ \t]*[\"']?\$\{\{\s*secrets\[\s*format\(.*$"
|
|
50
|
+
)
|
|
51
|
+
DEFAULT_SERVICE_URL = "https://bitfab.ai"
|
|
52
|
+
OIDC_AUDIENCE = "bitfab-cloud-replay"
|
|
53
|
+
ID_TOKEN_PERMISSION = re.compile(
|
|
54
|
+
r"^[ \t]*(\"?id-token\"?[ \t]*:[ \t]*[\"']?write[\"']?|\"?permissions\"?[ \t]*:[ \t]*[\"']?write-all[\"']?)[ \t]*(#.*)?$",
|
|
55
|
+
re.MULTILINE,
|
|
46
56
|
)
|
|
47
57
|
API_KEY_PROBE_SECONDS = 120
|
|
48
58
|
RUN_NAME = "Bitfab replay ${{ inputs.execution_id || github.ref_name }}"
|
|
@@ -221,13 +231,15 @@ values --cloud-env sets. version and id are optional: version names the request
|
|
|
221
231
|
format (4 when env is set, otherwise 3; 2 is still accepted), and id is the
|
|
222
232
|
execution UUID, which must match an execution_id input when the workflow has one.
|
|
223
233
|
|
|
224
|
-
The replay runs
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
234
|
+
The replay runs as the person who started it, so the experiment is recorded as
|
|
235
|
+
yours. Before dispatch, the replay's registry is loaded locally to read its
|
|
236
|
+
client's key, which asks Bitfab for a one-time token passed as the key_id input,
|
|
237
|
+
tied to the repository, branch, and workflow of the run. The runner trades that
|
|
238
|
+
token, together with the GitHub Actions OIDC token of its own run, for a key that
|
|
239
|
+
works only until the replay ends, so only that run can use it; the token alone
|
|
240
|
+
opens nothing. With no key resolved locally, a token Bitfab will not issue or
|
|
241
|
+
trade, or a workflow that declares no key_id input or does not grant
|
|
242
|
+
id-token: write, the run uses the workflow's BITFAB_API_KEY secret instead.
|
|
231
243
|
|
|
232
244
|
The runner's job exits with the replay's exit code, so the Actions run fails when
|
|
233
245
|
the replay does. Traces that errored fail it only under --fail-on-error.
|
|
@@ -243,8 +255,11 @@ Set up once:
|
|
|
243
255
|
Writes .github/workflows/bitfab-replay.yml, the only file cloud replay keeps
|
|
244
256
|
in the repository, and lists what is left to do. On an existing workflow it
|
|
245
257
|
adds only the lines a newer SDK needs and lists what it changed.
|
|
246
|
-
--cloud-update [same options]
|
|
258
|
+
--cloud-update [same options] [--remove-old-key-secrets]
|
|
247
259
|
The same command as --cloud-init, for bringing an existing setup up to date.
|
|
260
|
+
It also lists BITFAB_REPLAY_KEY_* secrets an older SDK left in the
|
|
261
|
+
repository, each a copy of someone's Bitfab API key, and with
|
|
262
|
+
--remove-old-key-secrets deletes them.
|
|
248
263
|
--cloud-secrets --env-file FILE [NAME ...] [--environment NAME] [--dry-run]
|
|
249
264
|
Copies local values into the GitHub secrets the workflow reads.
|
|
250
265
|
|
|
@@ -1376,7 +1391,7 @@ def submit(root, repo, workflow, parsed, path):
|
|
|
1376
1391
|
save(path, record)
|
|
1377
1392
|
text = within(root, f"{WORKFLOW_DIRECTORY}/{workflow}").read_text()
|
|
1378
1393
|
declared = declared_inputs(text)
|
|
1379
|
-
key_inputs =
|
|
1394
|
+
key_inputs = issue_key_token(root, record, text)
|
|
1380
1395
|
save(path, record)
|
|
1381
1396
|
return dispatch(
|
|
1382
1397
|
record,
|
|
@@ -1385,7 +1400,7 @@ def submit(root, repo, workflow, parsed, path):
|
|
|
1385
1400
|
)
|
|
1386
1401
|
|
|
1387
1402
|
|
|
1388
|
-
def
|
|
1403
|
+
def local_client(root, request):
|
|
1389
1404
|
try:
|
|
1390
1405
|
output = command(
|
|
1391
1406
|
[*replay_command(), *request["args"], PRINT_API_KEY_FLAG],
|
|
@@ -1399,59 +1414,101 @@ def local_api_key(root, request):
|
|
|
1399
1414
|
if not line.startswith(API_KEY_LINE):
|
|
1400
1415
|
continue
|
|
1401
1416
|
try:
|
|
1402
|
-
|
|
1403
|
-
except
|
|
1417
|
+
reported = json.loads(line.removeprefix(API_KEY_LINE))
|
|
1418
|
+
except ValueError:
|
|
1419
|
+
return None
|
|
1420
|
+
if not isinstance(reported, dict):
|
|
1404
1421
|
return None
|
|
1405
|
-
|
|
1422
|
+
key = reported.get("apiKey")
|
|
1423
|
+
if not isinstance(key, str) or not key.strip():
|
|
1424
|
+
return None
|
|
1425
|
+
url = reported.get("serviceUrl")
|
|
1426
|
+
if not isinstance(url, str) or not url.strip():
|
|
1427
|
+
url = DEFAULT_SERVICE_URL
|
|
1428
|
+
return {"apiKey": key, "serviceUrl": url.rstrip("/")}
|
|
1406
1429
|
return None
|
|
1407
1430
|
|
|
1408
1431
|
|
|
1409
|
-
def
|
|
1432
|
+
def bitfab_api(client, path, *, method="POST", payload=None):
|
|
1433
|
+
data = None if payload is None else json.dumps(payload).encode()
|
|
1434
|
+
request = urllib.request.Request(
|
|
1435
|
+
f"{client['serviceUrl']}/api/sdk/{path}",
|
|
1436
|
+
method=method,
|
|
1437
|
+
data=data,
|
|
1438
|
+
headers={
|
|
1439
|
+
**(
|
|
1440
|
+
{"Authorization": f"Bearer {client['apiKey']}"}
|
|
1441
|
+
if client.get("apiKey")
|
|
1442
|
+
else {}
|
|
1443
|
+
),
|
|
1444
|
+
"User-Agent": "bitfab-cloud-replay",
|
|
1445
|
+
**({"Content-Type": "application/json"} if data is not None else {}),
|
|
1446
|
+
},
|
|
1447
|
+
)
|
|
1448
|
+
try:
|
|
1449
|
+
with urllib.request.urlopen(request, timeout=60) as response:
|
|
1450
|
+
body = response.read()
|
|
1451
|
+
except urllib.error.HTTPError as error:
|
|
1452
|
+
try:
|
|
1453
|
+
reason = json.loads(error.read()).get("error")
|
|
1454
|
+
except (ValueError, AttributeError, OSError):
|
|
1455
|
+
reason = None
|
|
1456
|
+
raise CommandError(
|
|
1457
|
+
f"Bitfab {method} {path} failed (HTTP {error.code})"
|
|
1458
|
+
+ (f": {reason}" if reason else ""),
|
|
1459
|
+
error.code,
|
|
1460
|
+
) from None
|
|
1461
|
+
except (urllib.error.URLError, OSError) as error:
|
|
1462
|
+
raise CommandError(
|
|
1463
|
+
f"Could not reach {client['serviceUrl']}: {getattr(error, 'reason', error)}"
|
|
1464
|
+
) from None
|
|
1465
|
+
return json.loads(body) if body.strip() else {}
|
|
1466
|
+
|
|
1467
|
+
|
|
1468
|
+
def grants_id_token(text):
|
|
1469
|
+
return ID_TOKEN_PERMISSION.search(text) is not None
|
|
1470
|
+
|
|
1471
|
+
|
|
1472
|
+
def issue_key_token(root, record, text):
|
|
1410
1473
|
shared = "so the run uses the workflow's BITFAB_API_KEY secret"
|
|
1411
|
-
if KEY_INPUT not in
|
|
1474
|
+
if KEY_INPUT not in declared_inputs(text):
|
|
1412
1475
|
progress(
|
|
1413
|
-
f"The workflow takes no {KEY_INPUT} input, {shared}. To replay
|
|
1476
|
+
f"The workflow takes no {KEY_INPUT} input, {shared}. To replay as yourself, run bitfab-replay --cloud-update, which adds the {KEY_INPUT} input to the workflow"
|
|
1414
1477
|
)
|
|
1415
1478
|
return {}
|
|
1416
|
-
if
|
|
1479
|
+
if not grants_id_token(text):
|
|
1417
1480
|
progress(
|
|
1418
|
-
f"
|
|
1481
|
+
f"The workflow does not grant id-token: write, so its run cannot prove to Bitfab which run it is, {shared}. To replay as yourself, run bitfab-replay --cloud-update, which adds it"
|
|
1419
1482
|
)
|
|
1420
1483
|
return {}
|
|
1421
|
-
|
|
1422
|
-
if
|
|
1484
|
+
client = local_client(root, record["request"])
|
|
1485
|
+
if client is None:
|
|
1423
1486
|
progress(
|
|
1424
1487
|
f"The replay's Bitfab client resolved no API key on this machine, {shared}"
|
|
1425
1488
|
)
|
|
1426
1489
|
return {}
|
|
1427
|
-
key_id = record["id"].replace("-", "").upper()
|
|
1428
|
-
name = KEY_SECRET_PREFIX + key_id
|
|
1429
|
-
configured = ENVIRONMENT_SETTING.search(workflow_text)
|
|
1430
|
-
environment = configured[1] if configured else None
|
|
1431
1490
|
try:
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
"
|
|
1437
|
-
|
|
1438
|
-
"
|
|
1439
|
-
record["
|
|
1440
|
-
|
|
1441
|
-
],
|
|
1442
|
-
input=key,
|
|
1443
|
-
env={**os.environ, "GH_TOKEN": github_access()["token"]},
|
|
1444
|
-
)
|
|
1445
|
-
except (ValueError, RuntimeError, OSError, subprocess.SubprocessError):
|
|
1446
|
-
progress(
|
|
1447
|
-
f"Could not store your Bitfab API key as the GitHub secret {name}, which needs permission to manage the repository's Actions secrets, {shared}"
|
|
1491
|
+
issued = bitfab_api(
|
|
1492
|
+
client,
|
|
1493
|
+
"replay/cloudKey",
|
|
1494
|
+
payload={
|
|
1495
|
+
"executionId": record["id"],
|
|
1496
|
+
"repository": record["repository"],
|
|
1497
|
+
"ref": "refs/heads/" + record["branch"],
|
|
1498
|
+
"workflow": record["workflow"],
|
|
1499
|
+
},
|
|
1448
1500
|
)
|
|
1501
|
+
except CommandError as error:
|
|
1502
|
+
progress(f"{error}, {shared}")
|
|
1503
|
+
return {}
|
|
1504
|
+
token = issued.get("token")
|
|
1505
|
+
if not isinstance(token, str) or not token.startswith(KEY_TOKEN_PREFIX):
|
|
1506
|
+
progress(f"Bitfab issued no replay token, {shared}")
|
|
1449
1507
|
return {}
|
|
1450
|
-
record["keySecret"] = {"name": name, "environment": environment}
|
|
1451
1508
|
progress(
|
|
1452
|
-
f"Replaying
|
|
1509
|
+
f"Replaying as you: the run gets a one-time token that only {record['workflow']} running on {record['branch']} in {record['repository']} can use"
|
|
1453
1510
|
)
|
|
1454
|
-
return {KEY_INPUT:
|
|
1511
|
+
return {KEY_INPUT: token}
|
|
1455
1512
|
|
|
1456
1513
|
|
|
1457
1514
|
def remove_key(record):
|
|
@@ -1606,7 +1663,7 @@ def submit_as_is(root, repo, workflow, parsed, path):
|
|
|
1606
1663
|
progress(
|
|
1607
1664
|
f"Replaying {branch} at {sha[:12]} as it is on GitHub, without local changes. Execution {execution_id}; resume with --cloud-watch {execution_id}"
|
|
1608
1665
|
)
|
|
1609
|
-
key_inputs =
|
|
1666
|
+
key_inputs = issue_key_token(root, record, text)
|
|
1610
1667
|
save(path, record)
|
|
1611
1668
|
return dispatch(
|
|
1612
1669
|
record,
|
|
@@ -1954,11 +2011,6 @@ def execute():
|
|
|
1954
2011
|
)
|
|
1955
2012
|
directory = within(root, request["cwd"])
|
|
1956
2013
|
check_secrets(root)
|
|
1957
|
-
if os.environ.get(API_KEY_ENV, "").strip():
|
|
1958
|
-
print(
|
|
1959
|
-
"Replaying with the Bitfab API key of the person who started this run",
|
|
1960
|
-
flush=True,
|
|
1961
|
-
)
|
|
1962
2014
|
environment = request.get("env") or {}
|
|
1963
2015
|
if environment:
|
|
1964
2016
|
workflow_environment_conflicts(root, environment)
|
|
@@ -1981,6 +2033,7 @@ def execute():
|
|
|
1981
2033
|
if dry_run(request["args"]):
|
|
1982
2034
|
run_check_command(directory, environment)
|
|
1983
2035
|
experiment = {}
|
|
2036
|
+
starter = starter_key(root, request)
|
|
1984
2037
|
previous = signal.signal(signal.SIGTERM, raise_interrupt)
|
|
1985
2038
|
print(OUTPUT_BEGIN, flush=True)
|
|
1986
2039
|
try:
|
|
@@ -1995,6 +2048,7 @@ def execute():
|
|
|
1995
2048
|
finally:
|
|
1996
2049
|
print(OUTPUT_END, flush=True)
|
|
1997
2050
|
signal.signal(signal.SIGTERM, previous)
|
|
2051
|
+
revoke_starter_key(starter)
|
|
1998
2052
|
result = {
|
|
1999
2053
|
"executionId": request["id"],
|
|
2000
2054
|
"commitSha": commit,
|
|
@@ -2011,6 +2065,114 @@ def execute():
|
|
|
2011
2065
|
return result
|
|
2012
2066
|
|
|
2013
2067
|
|
|
2068
|
+
def runner_warning(message):
|
|
2069
|
+
print(f"::warning title=Bitfab replay::{message}", flush=True)
|
|
2070
|
+
|
|
2071
|
+
|
|
2072
|
+
def warn_if_workflow_outdated():
|
|
2073
|
+
problems = []
|
|
2074
|
+
if API_KEY_ENV in os.environ:
|
|
2075
|
+
problems.append(
|
|
2076
|
+
f"still sets {API_KEY_ENV} from a secret named by the run's inputs, which makes GitHub give this run every secret in the repository"
|
|
2077
|
+
)
|
|
2078
|
+
if not os.environ.get("ACTIONS_ID_TOKEN_REQUEST_URL"):
|
|
2079
|
+
problems.append(
|
|
2080
|
+
"does not grant id-token: write, so replays use the workflow's BITFAB_API_KEY secret instead of running as the person who started them"
|
|
2081
|
+
)
|
|
2082
|
+
if problems:
|
|
2083
|
+
runner_warning(
|
|
2084
|
+
f"This workflow {' and '.join(problems)}. Run bitfab-replay --cloud-update and commit the workflow it updates"
|
|
2085
|
+
)
|
|
2086
|
+
|
|
2087
|
+
|
|
2088
|
+
def starter_key(root, request):
|
|
2089
|
+
warn_if_workflow_outdated()
|
|
2090
|
+
if os.environ.get(API_KEY_ENV, "").strip():
|
|
2091
|
+
print(
|
|
2092
|
+
"Replaying with the Bitfab API key of the person who started this run",
|
|
2093
|
+
flush=True,
|
|
2094
|
+
)
|
|
2095
|
+
return None
|
|
2096
|
+
token = str(dispatched_inputs().get(KEY_INPUT) or "").strip()
|
|
2097
|
+
if not token.startswith(KEY_TOKEN_PREFIX):
|
|
2098
|
+
return None
|
|
2099
|
+
shared = "so this run uses the workflow's BITFAB_API_KEY secret"
|
|
2100
|
+
runner = local_client(root, request)
|
|
2101
|
+
if runner is None:
|
|
2102
|
+
runner_warning(
|
|
2103
|
+
f"The replay's Bitfab client resolved no API key on the runner, {shared}"
|
|
2104
|
+
)
|
|
2105
|
+
return None
|
|
2106
|
+
try:
|
|
2107
|
+
identity = github_run_identity()
|
|
2108
|
+
except CommandError as error:
|
|
2109
|
+
runner_warning(f"{error}, {shared}")
|
|
2110
|
+
return None
|
|
2111
|
+
timeout = request.get("timeoutMinutes")
|
|
2112
|
+
try:
|
|
2113
|
+
redeemed = bitfab_api(
|
|
2114
|
+
{"serviceUrl": runner["serviceUrl"]},
|
|
2115
|
+
"replay/cloudKey/redeem",
|
|
2116
|
+
payload={
|
|
2117
|
+
"token": token,
|
|
2118
|
+
"oidcToken": identity,
|
|
2119
|
+
**({"minutes": timeout} if timeout else {}),
|
|
2120
|
+
},
|
|
2121
|
+
)
|
|
2122
|
+
except CommandError as error:
|
|
2123
|
+
runner_warning(f"{error}, {shared}")
|
|
2124
|
+
return None
|
|
2125
|
+
key = redeemed.get("apiKey")
|
|
2126
|
+
if not isinstance(key, str) or not key.strip():
|
|
2127
|
+
runner_warning(f"Bitfab returned no key for the replay token, {shared}")
|
|
2128
|
+
return None
|
|
2129
|
+
print(f"::add-mask::{key}", flush=True)
|
|
2130
|
+
os.environ[API_KEY_ENV] = key
|
|
2131
|
+
print(
|
|
2132
|
+
"Replaying as the person who started this run, with a key that is turned off when the replay ends",
|
|
2133
|
+
flush=True,
|
|
2134
|
+
)
|
|
2135
|
+
return {"apiKey": key, "serviceUrl": runner["serviceUrl"]}
|
|
2136
|
+
|
|
2137
|
+
|
|
2138
|
+
def github_run_identity():
|
|
2139
|
+
url = os.environ.get("ACTIONS_ID_TOKEN_REQUEST_URL")
|
|
2140
|
+
bearer = os.environ.get("ACTIONS_ID_TOKEN_REQUEST_TOKEN")
|
|
2141
|
+
if not url or not bearer:
|
|
2142
|
+
raise CommandError(
|
|
2143
|
+
"The workflow does not grant id-token: write, so this run cannot prove to Bitfab which run it is; run bitfab-replay --cloud-update to add it"
|
|
2144
|
+
)
|
|
2145
|
+
separator = "&" if "?" in url else "?"
|
|
2146
|
+
request = urllib.request.Request(
|
|
2147
|
+
f"{url}{separator}{urlencode({'audience': OIDC_AUDIENCE})}",
|
|
2148
|
+
headers={
|
|
2149
|
+
"Authorization": f"Bearer {bearer}",
|
|
2150
|
+
"User-Agent": "bitfab-cloud-replay",
|
|
2151
|
+
},
|
|
2152
|
+
)
|
|
2153
|
+
try:
|
|
2154
|
+
with urllib.request.urlopen(request, timeout=30) as response:
|
|
2155
|
+
value = json.loads(response.read()).get("value")
|
|
2156
|
+
except (urllib.error.URLError, OSError, ValueError, AttributeError) as error:
|
|
2157
|
+
raise CommandError(
|
|
2158
|
+
f"GitHub did not issue this run's OIDC token: {getattr(error, 'reason', error)}"
|
|
2159
|
+
) from None
|
|
2160
|
+
if not isinstance(value, str) or not value:
|
|
2161
|
+
raise CommandError("GitHub returned no OIDC token for this run")
|
|
2162
|
+
return value
|
|
2163
|
+
|
|
2164
|
+
|
|
2165
|
+
def revoke_starter_key(starter):
|
|
2166
|
+
if starter is None:
|
|
2167
|
+
return
|
|
2168
|
+
try:
|
|
2169
|
+
bitfab_api(starter, "replay/cloudKey", method="DELETE")
|
|
2170
|
+
except CommandError as error:
|
|
2171
|
+
runner_warning(
|
|
2172
|
+
f"Could not turn off this replay's key ({error}); it expires on its own 30 minutes after the replay's timeout"
|
|
2173
|
+
)
|
|
2174
|
+
|
|
2175
|
+
|
|
2014
2176
|
def running_workflow(root):
|
|
2015
2177
|
reference = os.environ.get("GITHUB_WORKFLOW_REF", "")
|
|
2016
2178
|
match = re.search(r"(\.github/workflows/[^@]+)@", reference)
|
|
@@ -2656,7 +2818,7 @@ def workflow_document(steps, run, directory, env, *, runs_on, environment):
|
|
|
2656
2818
|
}
|
|
2657
2819
|
}
|
|
2658
2820
|
},
|
|
2659
|
-
"permissions": {"contents": "read"},
|
|
2821
|
+
"permissions": {"contents": "read", "id-token": "write"},
|
|
2660
2822
|
"jobs": {"replay": job},
|
|
2661
2823
|
}
|
|
2662
2824
|
|
|
@@ -2712,38 +2874,11 @@ def without_yaml_comment(line):
|
|
|
2712
2874
|
return re.sub(r"(^|\s)#.*", "", line)
|
|
2713
2875
|
|
|
2714
2876
|
|
|
2715
|
-
def
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
continue
|
|
2719
|
-
for above in range(index, -1, -1):
|
|
2720
|
-
match = re.match(r"([ \t]*)(- +)?run[ \t]*:", lines[above])
|
|
2721
|
-
if match and indentation(lines[above]) <= indentation(line):
|
|
2722
|
-
return above, len(match[1]) + len(match[2] or "")
|
|
2723
|
-
return None
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
def add_key_env(lines):
|
|
2727
|
-
found = replay_step_key(lines)
|
|
2728
|
-
if found is None:
|
|
2877
|
+
def remove_legacy_key_env(lines):
|
|
2878
|
+
kept = [line for line in lines if not LEGACY_KEY_SECRET_REFERENCE.match(line)]
|
|
2879
|
+
if len(kept) == len(lines):
|
|
2729
2880
|
return False
|
|
2730
|
-
|
|
2731
|
-
start = run
|
|
2732
|
-
while start > 0 and not re.match(r"[ \t]*- ", lines[start]):
|
|
2733
|
-
start -= 1
|
|
2734
|
-
end = block_end(lines, run, key - 1)
|
|
2735
|
-
entry = f'{API_KEY_ENV}: "{KEY_SECRET_REFERENCE}"'
|
|
2736
|
-
for index in range(start, end):
|
|
2737
|
-
line = lines[index]
|
|
2738
|
-
if not re.fullmatch(r"[ \t]*(- +)?env[ \t]*:[ \t]*", line):
|
|
2739
|
-
continue
|
|
2740
|
-
if len(line) - len(line.lstrip(" \t-")) != key:
|
|
2741
|
-
continue
|
|
2742
|
-
entries = block_end(lines, index, key)
|
|
2743
|
-
child = indentation(lines[index + 1]) if entries > index + 1 else key + 2
|
|
2744
|
-
lines.insert(entries, " " * child + entry)
|
|
2745
|
-
return True
|
|
2746
|
-
lines[end:end] = [" " * key + "env:", " " * (key + 2) + entry]
|
|
2881
|
+
lines[:] = kept
|
|
2747
2882
|
return True
|
|
2748
2883
|
|
|
2749
2884
|
|
|
@@ -2798,7 +2933,100 @@ def add_checkout_depth(lines):
|
|
|
2798
2933
|
return "unchanged"
|
|
2799
2934
|
|
|
2800
2935
|
|
|
2801
|
-
def
|
|
2936
|
+
def add_id_token_permission(lines):
|
|
2937
|
+
for index, line in enumerate(lines):
|
|
2938
|
+
if not re.fullmatch(r'"?permissions"?[ \t]*:[ \t]*(#.*)?', line):
|
|
2939
|
+
continue
|
|
2940
|
+
end = block_end(lines, index, 0)
|
|
2941
|
+
child = next(
|
|
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")
|
|
2950
|
+
return True
|
|
2951
|
+
return False
|
|
2952
|
+
|
|
2953
|
+
|
|
2954
|
+
def secret_scope(environment):
|
|
2955
|
+
return f"environments/{environment}/" if environment else "actions/"
|
|
2956
|
+
|
|
2957
|
+
|
|
2958
|
+
def secret_names(repo, environment):
|
|
2959
|
+
names, page = [], 1
|
|
2960
|
+
while True:
|
|
2961
|
+
listed = (
|
|
2962
|
+
api(
|
|
2963
|
+
repo,
|
|
2964
|
+
f"{secret_scope(environment)}secrets?per_page={SECRETS_PER_PAGE}&page={page}",
|
|
2965
|
+
)
|
|
2966
|
+
or {}
|
|
2967
|
+
)
|
|
2968
|
+
batch = [secret["name"] for secret in listed.get("secrets", [])]
|
|
2969
|
+
names.extend(batch)
|
|
2970
|
+
if len(batch) < SECRETS_PER_PAGE:
|
|
2971
|
+
return names
|
|
2972
|
+
page += 1
|
|
2973
|
+
|
|
2974
|
+
|
|
2975
|
+
def leftover_key_secrets(repo, environment):
|
|
2976
|
+
return [
|
|
2977
|
+
{"name": name, "environment": scope}
|
|
2978
|
+
for scope in dict.fromkeys([None, environment])
|
|
2979
|
+
for name in secret_names(repo, scope)
|
|
2980
|
+
if name.startswith(LEFTOVER_KEY_SECRET_PREFIX)
|
|
2981
|
+
]
|
|
2982
|
+
|
|
2983
|
+
|
|
2984
|
+
def delete_secret(repo, secret):
|
|
2985
|
+
try:
|
|
2986
|
+
api(
|
|
2987
|
+
repo,
|
|
2988
|
+
f"{secret_scope(secret['environment'])}secrets/{secret['name']}",
|
|
2989
|
+
method="DELETE",
|
|
2990
|
+
)
|
|
2991
|
+
except CommandError as error:
|
|
2992
|
+
if error.http_status != 404:
|
|
2993
|
+
return False
|
|
2994
|
+
return True
|
|
2995
|
+
|
|
2996
|
+
|
|
2997
|
+
def leftover_key_secret_report(root, text, remove):
|
|
2998
|
+
try:
|
|
2999
|
+
repo = repository(root)
|
|
3000
|
+
except ValueError:
|
|
3001
|
+
return {}, []
|
|
3002
|
+
configured = ENVIRONMENT_SETTING.search(text)
|
|
3003
|
+
environment = configured[1] if configured else None
|
|
3004
|
+
pages = " and ".join(
|
|
3005
|
+
dict.fromkeys(secrets_page(repo, scope) for scope in (None, environment))
|
|
3006
|
+
)
|
|
3007
|
+
try:
|
|
3008
|
+
found = leftover_key_secrets(repo, environment)
|
|
3009
|
+
except (ValueError, RuntimeError):
|
|
3010
|
+
return {}, [
|
|
3011
|
+
f"Could not list the repository's secrets to look for {LEFTOVER_KEY_SECRET_PREFIX}* secrets an older SDK left behind, each a copy of someone's Bitfab API key; delete any at {pages}"
|
|
3012
|
+
]
|
|
3013
|
+
if not found:
|
|
3014
|
+
return {}, []
|
|
3015
|
+
if not remove:
|
|
3016
|
+
return {"oldKeySecrets": [secret["name"] for secret in found]}, [
|
|
3017
|
+
f"Delete {len(found)} {LEFTOVER_KEY_SECRET_PREFIX}* secret{'s' if len(found) != 1 else ''} an older SDK left behind when a replay could not remove {'them' if len(found) != 1 else 'it'}. Each holds a copy of the Bitfab API key of the person who started that replay, and nothing reads them now. Run bitfab-replay --cloud-update --remove-old-key-secrets, or delete them at {pages}"
|
|
3018
|
+
]
|
|
3019
|
+
removed = [secret for secret in found if delete_secret(repo, secret)]
|
|
3020
|
+
failed = [secret["name"] for secret in found if secret not in removed]
|
|
3021
|
+
report = {"removedOldKeySecrets": [secret["name"] for secret in removed]}
|
|
3022
|
+
if not failed:
|
|
3023
|
+
return report, []
|
|
3024
|
+
return {**report, "oldKeySecrets": failed}, [
|
|
3025
|
+
f"Could not delete {', '.join(failed)}, which needs permission to manage the repository's Actions secrets; delete them at {pages}"
|
|
3026
|
+
]
|
|
3027
|
+
|
|
3028
|
+
|
|
3029
|
+
def update_workflow(root, path, *, remove_old_key_secrets=False):
|
|
2802
3030
|
target = within(root, path)
|
|
2803
3031
|
text = target.read_text()
|
|
2804
3032
|
lines = text.split("\n")
|
|
@@ -2816,13 +3044,19 @@ def update_workflow(root, path):
|
|
|
2816
3044
|
if not re.search(r'^"?run-name"?[ \t]*:', text, re.MULTILINE):
|
|
2817
3045
|
add_run_name(lines)
|
|
2818
3046
|
updated.append("Named each run after its execution")
|
|
2819
|
-
if
|
|
2820
|
-
if
|
|
2821
|
-
updated.append(
|
|
3047
|
+
if not grants_id_token(text):
|
|
3048
|
+
if add_id_token_permission(lines):
|
|
3049
|
+
updated.append(
|
|
3050
|
+
"Granted id-token: write, so a run can prove to Bitfab which run it is and get the key of the person who started it"
|
|
3051
|
+
)
|
|
2822
3052
|
else:
|
|
2823
3053
|
by_hand.append(
|
|
2824
|
-
|
|
3054
|
+
"id-token: write under the workflow's top-level permissions block"
|
|
2825
3055
|
)
|
|
3056
|
+
if remove_legacy_key_env(lines):
|
|
3057
|
+
updated.append(
|
|
3058
|
+
f"Removed the {API_KEY_ENV} line, which read a secret by a name built from the run's inputs and so gave the runner every secret in the repository; the runner now gets the key from Bitfab"
|
|
3059
|
+
)
|
|
2826
3060
|
depth = add_checkout_depth(lines)
|
|
2827
3061
|
if depth == "updated":
|
|
2828
3062
|
updated.append(
|
|
@@ -2848,18 +3082,22 @@ def update_workflow(root, path):
|
|
|
2848
3082
|
steps.append(
|
|
2849
3083
|
"Already set up. Edit the workflow directly to change its install steps, secrets, runner, or Environment"
|
|
2850
3084
|
)
|
|
3085
|
+
leftovers, leftover_steps = leftover_key_secret_report(
|
|
3086
|
+
root, text, remove_old_key_secrets
|
|
3087
|
+
)
|
|
2851
3088
|
return {
|
|
2852
3089
|
"files": [path] if updated else [],
|
|
2853
3090
|
"workflow": path,
|
|
2854
3091
|
"updated": updated,
|
|
2855
|
-
|
|
3092
|
+
**leftovers,
|
|
3093
|
+
"next": [*steps, *leftover_steps],
|
|
2856
3094
|
}
|
|
2857
3095
|
|
|
2858
3096
|
|
|
2859
3097
|
def initialize(argv, name="--cloud-init"):
|
|
2860
3098
|
parser = argparse.ArgumentParser(
|
|
2861
3099
|
prog=f"bitfab-replay {name}",
|
|
2862
|
-
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
|
|
3100
|
+
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.",
|
|
2863
3101
|
)
|
|
2864
3102
|
parser.add_argument(
|
|
2865
3103
|
"--secret",
|
|
@@ -2882,6 +3120,11 @@ def initialize(argv, name="--cloud-init"):
|
|
|
2882
3120
|
help='Command that starts the SDK\'s bitfab-replay on the runner, such as "pnpm exec bitfab-replay"; detected for every SDK, including the Go program that calls RunCloudReplayCLI',
|
|
2883
3121
|
)
|
|
2884
3122
|
parser.add_argument("--runs-on", default="ubuntu-24.04", help="Runner label")
|
|
3123
|
+
parser.add_argument(
|
|
3124
|
+
"--remove-old-key-secrets",
|
|
3125
|
+
action="store_true",
|
|
3126
|
+
help=f"On an existing workflow, also delete the {LEFTOVER_KEY_SECRET_PREFIX}* GitHub secrets an older SDK left behind, each a copy of the Bitfab API key of the person who started a replay",
|
|
3127
|
+
)
|
|
2885
3128
|
parser.add_argument(
|
|
2886
3129
|
"--check",
|
|
2887
3130
|
help='Command the runner also runs on a --cloud --dry-run, such as "node scripts/checkBucket.js"',
|
|
@@ -2891,7 +3134,11 @@ def initialize(argv, name="--cloud-init"):
|
|
|
2891
3134
|
refuse_old_setup(root)
|
|
2892
3135
|
existing = find_workflow(root)
|
|
2893
3136
|
if existing is not None:
|
|
2894
|
-
return update_workflow(
|
|
3137
|
+
return update_workflow(
|
|
3138
|
+
root,
|
|
3139
|
+
f"{WORKFLOW_DIRECTORY}/{existing}",
|
|
3140
|
+
remove_old_key_secrets=args.remove_old_key_secrets,
|
|
3141
|
+
)
|
|
2895
3142
|
prefix = args.secret_prefix
|
|
2896
3143
|
if prefix and not re.fullmatch(r"[A-Z][A-Z0-9_]*_", prefix):
|
|
2897
3144
|
raise ValueError(
|
|
@@ -2910,7 +3157,6 @@ def initialize(argv, name="--cloud-init"):
|
|
|
2910
3157
|
'Pass --run with the command that starts your registry program on the runner, such as --run "go run ./cmd/registry"'
|
|
2911
3158
|
)
|
|
2912
3159
|
env = {name: "${{ secrets." + prefix + name + " }}" for name in names}
|
|
2913
|
-
env[API_KEY_ENV] = KEY_SECRET_REFERENCE
|
|
2914
3160
|
if args.check:
|
|
2915
3161
|
env[CHECK_COMMAND_ENV] = args.check
|
|
2916
3162
|
path = f"{WORKFLOW_DIRECTORY}/{WORKFLOW}"
|
data/lib/bitfab/replay_cli.rb
CHANGED
|
@@ -53,8 +53,9 @@ module Bitfab
|
|
|
53
53
|
|
|
54
54
|
def print_api_key(registry, replay_argv, stdout:)
|
|
55
55
|
pipeline = ReplayCli.parse(registry, replay_argv.dup).fetch(:pipeline)
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
client = registry.fetch(pipeline).client
|
|
57
|
+
key = client.send(:lookup_api_key)
|
|
58
|
+
stdout.puts "#{BITFAB_API_KEY_PREFIX}#{JSON.generate({apiKey: key&.to_s, serviceUrl: client.service_url})}"
|
|
58
59
|
nil
|
|
59
60
|
end
|
|
60
61
|
|
data/lib/bitfab/version.rb
CHANGED