vouchington-tooling 0.0.4 → 0.0.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/README.md +10 -0
- package/dist/cli/commands/allocate-browser-safe-ports.slot-fixtures.test-helpers.d.mts +6 -0
- package/dist/cli/commands/allocate-browser-safe-ports.slot-fixtures.test-helpers.mjs +103 -0
- package/dist/cli/commands/github-output.test-helpers.d.mts +1 -0
- package/dist/cli/commands/github-output.test-helpers.mjs +32 -0
- package/dist/cli/commands/pnpm-install.d.mts +1 -0
- package/dist/cli/commands/pnpm-install.mjs +27 -0
- package/dist/cli/commands/spawn-script.d.mts +5 -0
- package/dist/cli/commands/spawn-script.mjs +7 -0
- package/dist/cli/commands/vitest-blob-manifest.d.mts +1 -0
- package/dist/cli/commands/vitest-blob-manifest.mjs +11 -0
- package/dist/cli/index.mjs +28 -0
- package/dist/cli/parse.d.mts +11 -0
- package/dist/cli/parse.mjs +14 -0
- package/dist/cli/script-path.d.mts +1 -0
- package/dist/cli/script-path.mjs +4 -0
- package/dist/cli/usage.d.mts +1 -1
- package/dist/cli/usage.mjs +16 -1
- package/dist/index.d.mts +6 -0
- package/dist/index.mjs +3 -0
- package/dist/pnpm-install/exec.d.mts +8 -0
- package/dist/pnpm-install/exec.mjs +88 -0
- package/dist/pnpm-install/index.d.mts +7 -0
- package/dist/pnpm-install/index.mjs +6 -0
- package/dist/pnpm-install/metadata.d.mts +5 -0
- package/dist/pnpm-install/metadata.mjs +91 -0
- package/dist/pnpm-install/pnpm-install-fixture.test-helpers.d.mts +22 -0
- package/dist/pnpm-install/pnpm-install-fixture.test-helpers.mjs +174 -0
- package/dist/pnpm-install/process.d.mts +11 -0
- package/dist/pnpm-install/process.mjs +81 -0
- package/dist/pnpm-install/release-age.d.mts +9 -0
- package/dist/pnpm-install/release-age.mjs +59 -0
- package/dist/pnpm-install/runner.d.mts +2 -0
- package/dist/pnpm-install/runner.mjs +106 -0
- package/dist/pnpm-install/support.d.mts +30 -0
- package/dist/pnpm-install/support.mjs +143 -0
- package/dist/shared-context/fake-git.test-helpers.d.mts +11 -0
- package/dist/shared-context/fake-git.test-helpers.mjs +59 -0
- package/dist/shared-context/index.d.mts +26 -0
- package/dist/shared-context/index.mjs +89 -0
- package/dist/vitest-blob-manifest/cli.d.mts +2 -0
- package/dist/vitest-blob-manifest/cli.mjs +33 -0
- package/dist/vitest-blob-manifest/index.d.mts +37 -0
- package/dist/vitest-blob-manifest/index.mjs +134 -0
- package/package.json +16 -1
- package/scripts/allocate-browser-safe-ports.py +661 -0
- package/scripts/fetch-forbidden-ports.json +7 -0
- package/scripts/gha/check-needs-results.sh +19 -0
- package/scripts/gha/download-with-diagnostics.sh +52 -0
- package/scripts/gha/host-pressure-diagnostics.sh +179 -0
- package/scripts/gha/write-github-multiline-output.sh +60 -0
- package/scripts/runner-port-policy.json +7 -0
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import errno
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import signal
|
|
10
|
+
import socket
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
17
|
+
FETCH_FORBIDDEN_PORTS_PATH = SCRIPT_DIR / "fetch-forbidden-ports.json"
|
|
18
|
+
RUNNER_PORT_POLICY_PATH = SCRIPT_DIR / "runner-port-policy.json"
|
|
19
|
+
CHROMIUM_RESTRICTED_PORTS: frozenset[int] = frozenset()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_forbidden_ports(path: Path) -> frozenset[int]:
|
|
23
|
+
return frozenset(json.loads(path.read_text()))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_runner_port_policy(path: Path) -> dict[str, int]:
|
|
27
|
+
policy = json.loads(path.read_text())
|
|
28
|
+
required_keys = {
|
|
29
|
+
"reservedPortStart",
|
|
30
|
+
"reservedPortEnd",
|
|
31
|
+
"portsPerRunner",
|
|
32
|
+
"minimumRunnerSlot",
|
|
33
|
+
"maximumRunnerSlot",
|
|
34
|
+
}
|
|
35
|
+
if set(policy) != required_keys or not all(
|
|
36
|
+
type(policy[key]) is int for key in required_keys
|
|
37
|
+
):
|
|
38
|
+
raise RuntimeError(f"invalid runner port policy at {path}")
|
|
39
|
+
|
|
40
|
+
start = policy["reservedPortStart"]
|
|
41
|
+
end = policy["reservedPortEnd"]
|
|
42
|
+
ports_per_runner = policy["portsPerRunner"]
|
|
43
|
+
minimum_slot = policy["minimumRunnerSlot"]
|
|
44
|
+
maximum_slot = policy["maximumRunnerSlot"]
|
|
45
|
+
if (
|
|
46
|
+
start < 1
|
|
47
|
+
or end > 65535
|
|
48
|
+
or start > end
|
|
49
|
+
or ports_per_runner <= 0
|
|
50
|
+
or minimum_slot != 1
|
|
51
|
+
or maximum_slot < minimum_slot
|
|
52
|
+
or end - start + 1 != ports_per_runner * maximum_slot
|
|
53
|
+
):
|
|
54
|
+
raise RuntimeError(f"invalid runner port policy at {path}")
|
|
55
|
+
return policy
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
RUNNER_PORT_POLICY: dict[str, int] = {}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def configure_policy(policy_path: Path, forbidden_path: Path) -> None:
|
|
62
|
+
global CHROMIUM_RESTRICTED_PORTS, FETCH_FORBIDDEN_PORTS_PATH, RUNNER_PORT_POLICY
|
|
63
|
+
global RUNNER_PORT_POLICY_PATH
|
|
64
|
+
FETCH_FORBIDDEN_PORTS_PATH = forbidden_path
|
|
65
|
+
RUNNER_PORT_POLICY_PATH = policy_path
|
|
66
|
+
CHROMIUM_RESTRICTED_PORTS = load_forbidden_ports(forbidden_path)
|
|
67
|
+
RUNNER_PORT_POLICY = load_runner_port_policy(policy_path)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def positive_int(value: str) -> int:
|
|
71
|
+
parsed = int(value)
|
|
72
|
+
if parsed <= 0:
|
|
73
|
+
raise argparse.ArgumentTypeError("must be greater than zero")
|
|
74
|
+
return parsed
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def parse_args() -> argparse.Namespace:
|
|
78
|
+
parser = argparse.ArgumentParser(
|
|
79
|
+
description="Allocate localhost ports that Chromium will load."
|
|
80
|
+
)
|
|
81
|
+
parser.add_argument("count", nargs="?", type=positive_int)
|
|
82
|
+
parser.add_argument("--max-bind-attempts", type=int, default=1000)
|
|
83
|
+
parser.add_argument("--hold", action="store_true")
|
|
84
|
+
parser.add_argument("--hold-dir", type=Path)
|
|
85
|
+
parser.add_argument("--release", type=positive_int)
|
|
86
|
+
parser.add_argument("--stop", action="store_true")
|
|
87
|
+
parser.add_argument("--check", action="store_true")
|
|
88
|
+
parser.add_argument(
|
|
89
|
+
"--workspace",
|
|
90
|
+
default=os.environ.get("PORT_HOLD_WORKSPACE")
|
|
91
|
+
or os.environ.get("VOUCHA_PORT_HOLD_WORKSPACE")
|
|
92
|
+
or os.environ.get("GITHUB_WORKSPACE")
|
|
93
|
+
or os.getcwd(),
|
|
94
|
+
)
|
|
95
|
+
parser.add_argument("--policy", type=Path, default=RUNNER_PORT_POLICY_PATH)
|
|
96
|
+
parser.add_argument("--forbidden-ports", type=Path, default=FETCH_FORBIDDEN_PORTS_PATH)
|
|
97
|
+
args = parser.parse_args()
|
|
98
|
+
configure_policy(args.policy, args.forbidden_ports)
|
|
99
|
+
control_modes = [args.release is not None, args.stop, args.check]
|
|
100
|
+
if sum(control_modes) > 1:
|
|
101
|
+
parser.error("use only one of --release, --stop, or --check")
|
|
102
|
+
if args.hold and any(control_modes):
|
|
103
|
+
parser.error("--hold cannot be combined with --release, --stop, or --check")
|
|
104
|
+
if (args.hold or any(control_modes)) and args.hold_dir is None:
|
|
105
|
+
parser.error("--hold-dir is required for --hold, --release, --stop, and --check")
|
|
106
|
+
if args.hold and args.count is None:
|
|
107
|
+
parser.error("count is required with --hold")
|
|
108
|
+
if not args.hold and not any(control_modes) and args.count is None:
|
|
109
|
+
parser.error("count is required")
|
|
110
|
+
return args
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def numeric_runner_slots(*paths: str) -> list[int]:
|
|
114
|
+
slots: list[int] = []
|
|
115
|
+
for path in paths:
|
|
116
|
+
if not path:
|
|
117
|
+
continue
|
|
118
|
+
parts = Path(path).parts
|
|
119
|
+
for index, part in enumerate(parts[:-2]):
|
|
120
|
+
if part not in {"actions-runner", "actions-runners"}:
|
|
121
|
+
continue
|
|
122
|
+
candidate = parts[index + 1]
|
|
123
|
+
if parts[index + 2] != "_work":
|
|
124
|
+
continue
|
|
125
|
+
if candidate.isdecimal():
|
|
126
|
+
if candidate != str(int(candidate)):
|
|
127
|
+
raise RuntimeError(
|
|
128
|
+
f"runner slot {candidate} must use canonical decimal spelling"
|
|
129
|
+
)
|
|
130
|
+
slots.append(int(candidate))
|
|
131
|
+
return slots
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def detect_runner_slot(github_workspace: str, cwd: str) -> int | None:
|
|
135
|
+
slots = numeric_runner_slots(github_workspace, cwd)
|
|
136
|
+
if not slots:
|
|
137
|
+
return None
|
|
138
|
+
unique_slots = set(slots)
|
|
139
|
+
if len(unique_slots) != 1:
|
|
140
|
+
raise RuntimeError(
|
|
141
|
+
"found conflicting numeric GitHub Actions runner slots in "
|
|
142
|
+
"GITHUB_WORKSPACE/current directory"
|
|
143
|
+
)
|
|
144
|
+
return slots[0]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def runner_port_slice(slot: int) -> range:
|
|
148
|
+
minimum_slot = RUNNER_PORT_POLICY["minimumRunnerSlot"]
|
|
149
|
+
maximum_slot = RUNNER_PORT_POLICY["maximumRunnerSlot"]
|
|
150
|
+
if slot < minimum_slot or slot > maximum_slot:
|
|
151
|
+
raise RuntimeError(
|
|
152
|
+
f"runner slot {slot} is outside the supported range "
|
|
153
|
+
f"{minimum_slot}-{maximum_slot}"
|
|
154
|
+
)
|
|
155
|
+
start = RUNNER_PORT_POLICY["reservedPortStart"] + (
|
|
156
|
+
slot - minimum_slot
|
|
157
|
+
) * RUNNER_PORT_POLICY["portsPerRunner"]
|
|
158
|
+
return range(start, start + RUNNER_PORT_POLICY["portsPerRunner"])
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def allocate_ports(
|
|
162
|
+
count: int, max_bind_attempts: int, runner_slot: int | None = None
|
|
163
|
+
) -> list[int]:
|
|
164
|
+
sockets: list[socket.socket] = []
|
|
165
|
+
try:
|
|
166
|
+
if runner_slot is not None:
|
|
167
|
+
port_slice = runner_port_slice(runner_slot)
|
|
168
|
+
if count > len(port_slice):
|
|
169
|
+
raise RuntimeError(
|
|
170
|
+
f"requested {count} ports exceeds runner slice capacity {len(port_slice)}"
|
|
171
|
+
)
|
|
172
|
+
candidates = port_slice
|
|
173
|
+
else:
|
|
174
|
+
candidates = range(max_bind_attempts)
|
|
175
|
+
|
|
176
|
+
for candidate_port in candidates:
|
|
177
|
+
if len(sockets) == count:
|
|
178
|
+
break
|
|
179
|
+
candidate = socket.socket()
|
|
180
|
+
try:
|
|
181
|
+
candidate.bind(("", candidate_port if runner_slot is not None else 0))
|
|
182
|
+
except OSError:
|
|
183
|
+
candidate.close()
|
|
184
|
+
continue
|
|
185
|
+
port = candidate.getsockname()[1]
|
|
186
|
+
if port in CHROMIUM_RESTRICTED_PORTS or (
|
|
187
|
+
runner_slot is None
|
|
188
|
+
and RUNNER_PORT_POLICY["reservedPortStart"]
|
|
189
|
+
<= port
|
|
190
|
+
<= RUNNER_PORT_POLICY["reservedPortEnd"]
|
|
191
|
+
):
|
|
192
|
+
candidate.close()
|
|
193
|
+
continue
|
|
194
|
+
sockets.append(candidate)
|
|
195
|
+
|
|
196
|
+
if len(sockets) != count:
|
|
197
|
+
location = (
|
|
198
|
+
f"runner slice {runner_slot}" if runner_slot is not None else "ephemeral ports"
|
|
199
|
+
)
|
|
200
|
+
raise RuntimeError(
|
|
201
|
+
f"failed to allocate {count} ports after {len(candidates)} bind attempts "
|
|
202
|
+
f"from {location}"
|
|
203
|
+
)
|
|
204
|
+
return [candidate.getsockname()[1] for candidate in sockets]
|
|
205
|
+
finally:
|
|
206
|
+
for candidate in sockets:
|
|
207
|
+
candidate.close()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
SCRIPT_NAME = "allocate-browser-safe-ports.py"
|
|
211
|
+
HOLD_POLL_SECONDS = 0.05
|
|
212
|
+
HOLD_READY_TIMEOUT_SECONDS = 2.0
|
|
213
|
+
CONTROL_WAIT_SECONDS = 2.0
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def resolve_workspace(workspace: str) -> str:
|
|
217
|
+
return str(Path(workspace).resolve())
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def durable_identity_dir(workspace: str) -> Path:
|
|
221
|
+
digest = hashlib.sha256(resolve_workspace(workspace).encode()).hexdigest()
|
|
222
|
+
return Path(f"/tmp/port-hold-{digest}")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def process_command_line(pid: int) -> str | None:
|
|
226
|
+
proc_cmd = Path(f"/proc/{pid}/cmdline")
|
|
227
|
+
try:
|
|
228
|
+
if proc_cmd.is_file():
|
|
229
|
+
return proc_cmd.read_bytes().replace(b"\x00", b" ").decode(errors="replace")
|
|
230
|
+
except OSError:
|
|
231
|
+
pass
|
|
232
|
+
result = subprocess.run(
|
|
233
|
+
["ps", "-p", str(pid), "-o", "command="],
|
|
234
|
+
capture_output=True,
|
|
235
|
+
text=True,
|
|
236
|
+
check=False,
|
|
237
|
+
)
|
|
238
|
+
if result.returncode != 0:
|
|
239
|
+
return None
|
|
240
|
+
return result.stdout.strip()
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def process_environ(pid: int) -> dict[str, str]:
|
|
244
|
+
proc_env = Path(f"/proc/{pid}/environ")
|
|
245
|
+
raw = b""
|
|
246
|
+
try:
|
|
247
|
+
if proc_env.is_file():
|
|
248
|
+
raw = proc_env.read_bytes()
|
|
249
|
+
except OSError:
|
|
250
|
+
raw = b""
|
|
251
|
+
if not raw:
|
|
252
|
+
result = subprocess.run(
|
|
253
|
+
["ps", "eww", "-p", str(pid), "-o", "command="],
|
|
254
|
+
capture_output=True,
|
|
255
|
+
check=False,
|
|
256
|
+
)
|
|
257
|
+
raw = result.stdout
|
|
258
|
+
values: dict[str, str] = {}
|
|
259
|
+
for entry in raw.split(b"\x00" if b"\x00" in raw else b" "):
|
|
260
|
+
if b"=" not in entry:
|
|
261
|
+
continue
|
|
262
|
+
key, value = entry.split(b"=", 1)
|
|
263
|
+
decoded_key = key.decode(errors="replace")
|
|
264
|
+
if decoded_key.isidentifier() or decoded_key.endswith("PORT_HOLD_WORKSPACE"):
|
|
265
|
+
values[decoded_key] = value.decode(errors="replace")
|
|
266
|
+
return values
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def is_our_holder(pid: int, workspace: str) -> bool:
|
|
270
|
+
command = process_command_line(pid)
|
|
271
|
+
if command is None or SCRIPT_NAME not in command:
|
|
272
|
+
return False
|
|
273
|
+
wanted = resolve_workspace(workspace)
|
|
274
|
+
for token in command.split():
|
|
275
|
+
try:
|
|
276
|
+
if resolve_workspace(token) == wanted:
|
|
277
|
+
return True
|
|
278
|
+
except OSError:
|
|
279
|
+
continue
|
|
280
|
+
environ = process_environ(pid)
|
|
281
|
+
recorded = environ.get("PORT_HOLD_WORKSPACE") or environ.get("VOUCHA_PORT_HOLD_WORKSPACE")
|
|
282
|
+
return recorded in {wanted, workspace}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def pid_is_alive(pid: int) -> bool:
|
|
286
|
+
try:
|
|
287
|
+
os.kill(pid, 0)
|
|
288
|
+
except OSError:
|
|
289
|
+
return False
|
|
290
|
+
return True
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def read_pid(path: Path) -> int | None:
|
|
294
|
+
try:
|
|
295
|
+
return int(path.read_text().strip())
|
|
296
|
+
except (ValueError, FileNotFoundError, ProcessLookupError):
|
|
297
|
+
return None
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def reap_stale_holder(workspace: str) -> None:
|
|
301
|
+
identity = durable_identity_dir(workspace)
|
|
302
|
+
pid_path = identity / "pid"
|
|
303
|
+
if not pid_path.is_file():
|
|
304
|
+
return
|
|
305
|
+
pid = read_pid(pid_path)
|
|
306
|
+
if pid is None:
|
|
307
|
+
pid_path.unlink(missing_ok=True)
|
|
308
|
+
return
|
|
309
|
+
if not pid_is_alive(pid):
|
|
310
|
+
pid_path.unlink(missing_ok=True)
|
|
311
|
+
return
|
|
312
|
+
if not is_our_holder(pid, workspace):
|
|
313
|
+
return
|
|
314
|
+
try:
|
|
315
|
+
os.kill(pid, signal.SIGTERM)
|
|
316
|
+
except ProcessLookupError:
|
|
317
|
+
pid_path.unlink(missing_ok=True)
|
|
318
|
+
return
|
|
319
|
+
deadline = time.monotonic() + CONTROL_WAIT_SECONDS
|
|
320
|
+
while time.monotonic() < deadline and pid_is_alive(pid):
|
|
321
|
+
time.sleep(HOLD_POLL_SECONDS)
|
|
322
|
+
if pid_is_alive(pid):
|
|
323
|
+
try:
|
|
324
|
+
os.kill(pid, signal.SIGKILL)
|
|
325
|
+
except ProcessLookupError:
|
|
326
|
+
pass
|
|
327
|
+
pid_path.unlink(missing_ok=True)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _bind_unlistening(family: int, address: tuple[str, int]) -> socket.socket:
|
|
331
|
+
bound = socket.socket(family, socket.SOCK_STREAM)
|
|
332
|
+
bound.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 0)
|
|
333
|
+
if family == socket.AF_INET6:
|
|
334
|
+
try:
|
|
335
|
+
bound.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
|
|
336
|
+
except OSError:
|
|
337
|
+
pass
|
|
338
|
+
try:
|
|
339
|
+
bound.bind(address)
|
|
340
|
+
except OSError:
|
|
341
|
+
bound.close()
|
|
342
|
+
raise
|
|
343
|
+
return bound
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def bind_hold_sockets(port: int | None) -> tuple[int, list[socket.socket]]:
|
|
347
|
+
sockets: list[socket.socket] = []
|
|
348
|
+
linux = sys.platform.startswith("linux")
|
|
349
|
+
chosen = port
|
|
350
|
+
|
|
351
|
+
# Linux Node/docker listen on ::: / 127.0.0.1. Binding loopback first then
|
|
352
|
+
# AF_INET6 V6ONLY=0 fails on every candidate (EADDRINUSE), which exhausted
|
|
353
|
+
# the 16-port runner slice in CI.
|
|
354
|
+
if linux and socket.has_ipv6:
|
|
355
|
+
try:
|
|
356
|
+
ipv6 = _bind_unlistening(socket.AF_INET6, ("::", 0 if port is None else port))
|
|
357
|
+
except OSError as error:
|
|
358
|
+
if error.errno in {errno.EAFNOSUPPORT, errno.EADDRNOTAVAIL}:
|
|
359
|
+
ipv6 = None
|
|
360
|
+
else:
|
|
361
|
+
raise
|
|
362
|
+
if ipv6 is not None:
|
|
363
|
+
chosen = ipv6.getsockname()[1]
|
|
364
|
+
sockets.append(ipv6)
|
|
365
|
+
for host in ("127.0.0.1", "0.0.0.0"):
|
|
366
|
+
try:
|
|
367
|
+
sockets.append(_bind_unlistening(socket.AF_INET, (host, chosen)))
|
|
368
|
+
except OSError:
|
|
369
|
+
pass
|
|
370
|
+
return chosen, sockets
|
|
371
|
+
|
|
372
|
+
loopback = _bind_unlistening(socket.AF_INET, ("127.0.0.1", 0 if port is None else port))
|
|
373
|
+
chosen = loopback.getsockname()[1]
|
|
374
|
+
sockets.append(loopback)
|
|
375
|
+
if socket.has_ipv6:
|
|
376
|
+
try:
|
|
377
|
+
sockets.append(_bind_unlistening(socket.AF_INET6, ("::", chosen)))
|
|
378
|
+
except OSError:
|
|
379
|
+
pass
|
|
380
|
+
try:
|
|
381
|
+
sockets.append(_bind_unlistening(socket.AF_INET, ("0.0.0.0", chosen)))
|
|
382
|
+
except OSError:
|
|
383
|
+
# Darwin cannot bind 0.0.0.0 after 127.0.0.1 with SO_REUSEADDR=0.
|
|
384
|
+
# Linux can, so a wildcard failure means a non-loopback IPv4 owner.
|
|
385
|
+
if linux:
|
|
386
|
+
for item in sockets:
|
|
387
|
+
item.close()
|
|
388
|
+
raise
|
|
389
|
+
return chosen, sockets
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def allocate_hold_sockets(
|
|
393
|
+
count: int, max_bind_attempts: int, runner_slot: int | None
|
|
394
|
+
) -> dict[int, list[socket.socket]]:
|
|
395
|
+
held: dict[int, list[socket.socket]] = {}
|
|
396
|
+
try:
|
|
397
|
+
if runner_slot is not None:
|
|
398
|
+
port_slice = runner_port_slice(runner_slot)
|
|
399
|
+
if count > len(port_slice):
|
|
400
|
+
raise RuntimeError(
|
|
401
|
+
f"requested {count} ports exceeds runner slice capacity {len(port_slice)}"
|
|
402
|
+
)
|
|
403
|
+
candidates: range | list[None] = port_slice
|
|
404
|
+
else:
|
|
405
|
+
candidates = [None] * max_bind_attempts
|
|
406
|
+
|
|
407
|
+
for candidate_port in candidates:
|
|
408
|
+
if len(held) == count:
|
|
409
|
+
break
|
|
410
|
+
try:
|
|
411
|
+
port, sockets = bind_hold_sockets(candidate_port)
|
|
412
|
+
except OSError:
|
|
413
|
+
continue
|
|
414
|
+
if port in CHROMIUM_RESTRICTED_PORTS or (
|
|
415
|
+
runner_slot is None
|
|
416
|
+
and RUNNER_PORT_POLICY["reservedPortStart"]
|
|
417
|
+
<= port
|
|
418
|
+
<= RUNNER_PORT_POLICY["reservedPortEnd"]
|
|
419
|
+
):
|
|
420
|
+
for item in sockets:
|
|
421
|
+
item.close()
|
|
422
|
+
continue
|
|
423
|
+
held[port] = sockets
|
|
424
|
+
|
|
425
|
+
if len(held) != count:
|
|
426
|
+
location = (
|
|
427
|
+
f"runner slice {runner_slot}" if runner_slot is not None else "ephemeral ports"
|
|
428
|
+
)
|
|
429
|
+
raise RuntimeError(
|
|
430
|
+
f"failed to allocate {count} ports after {len(candidates)} bind attempts "
|
|
431
|
+
f"from {location}"
|
|
432
|
+
)
|
|
433
|
+
return held
|
|
434
|
+
except Exception:
|
|
435
|
+
for sockets in held.values():
|
|
436
|
+
for item in sockets:
|
|
437
|
+
item.close()
|
|
438
|
+
raise
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def write_text(path: Path, content: str) -> None:
|
|
442
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
443
|
+
path.write_text(content)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def daemonize_holder() -> bool:
|
|
447
|
+
first = os.fork()
|
|
448
|
+
if first > 0:
|
|
449
|
+
os.waitpid(first, 0)
|
|
450
|
+
return False
|
|
451
|
+
os.setsid()
|
|
452
|
+
if os.fork() > 0:
|
|
453
|
+
os._exit(0)
|
|
454
|
+
os.chdir("/")
|
|
455
|
+
devnull = os.open(os.devnull, os.O_RDWR)
|
|
456
|
+
os.dup2(devnull, 0)
|
|
457
|
+
os.dup2(devnull, 1)
|
|
458
|
+
os.dup2(devnull, 2)
|
|
459
|
+
if devnull > 2:
|
|
460
|
+
os.close(devnull)
|
|
461
|
+
return True
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def holder_loop(
|
|
465
|
+
held: dict[int, list[socket.socket]],
|
|
466
|
+
hold_dir: Path,
|
|
467
|
+
workspace: str,
|
|
468
|
+
) -> None:
|
|
469
|
+
identity = durable_identity_dir(workspace)
|
|
470
|
+
write_text(identity / "workspace", resolve_workspace(workspace))
|
|
471
|
+
write_text(identity / "pid", str(os.getpid()))
|
|
472
|
+
write_text(hold_dir / "pid", str(os.getpid()))
|
|
473
|
+
write_text(hold_dir / "ports", " ".join(str(port) for port in held))
|
|
474
|
+
write_text(hold_dir / "ready", "1")
|
|
475
|
+
release_dir = hold_dir / "release"
|
|
476
|
+
release_dir.mkdir(parents=True, exist_ok=True)
|
|
477
|
+
|
|
478
|
+
def shutdown(_signum: int | None = None, _frame: object | None = None) -> None:
|
|
479
|
+
for sockets in held.values():
|
|
480
|
+
for item in sockets:
|
|
481
|
+
item.close()
|
|
482
|
+
held.clear()
|
|
483
|
+
(hold_dir / "stop").unlink(missing_ok=True)
|
|
484
|
+
(identity / "pid").unlink(missing_ok=True)
|
|
485
|
+
os._exit(0)
|
|
486
|
+
|
|
487
|
+
signal.signal(signal.SIGTERM, shutdown)
|
|
488
|
+
signal.signal(signal.SIGHUP, shutdown)
|
|
489
|
+
|
|
490
|
+
while True:
|
|
491
|
+
if not hold_dir.is_dir() or (hold_dir / "stop").is_file():
|
|
492
|
+
shutdown()
|
|
493
|
+
for release_file in list(release_dir.iterdir()):
|
|
494
|
+
try:
|
|
495
|
+
port = int(release_file.name)
|
|
496
|
+
except ValueError:
|
|
497
|
+
release_file.unlink(missing_ok=True)
|
|
498
|
+
continue
|
|
499
|
+
sockets = held.pop(port, [])
|
|
500
|
+
for item in sockets:
|
|
501
|
+
item.close()
|
|
502
|
+
release_file.unlink(missing_ok=True)
|
|
503
|
+
write_text(hold_dir / "ports", " ".join(str(item) for item in held))
|
|
504
|
+
time.sleep(HOLD_POLL_SECONDS)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def start_holder(
|
|
508
|
+
count: int,
|
|
509
|
+
max_bind_attempts: int,
|
|
510
|
+
runner_slot: int | None,
|
|
511
|
+
hold_dir: Path,
|
|
512
|
+
workspace: str,
|
|
513
|
+
) -> list[int]:
|
|
514
|
+
hold_dir = hold_dir.resolve()
|
|
515
|
+
hold_dir.mkdir(parents=True, exist_ok=True)
|
|
516
|
+
release_dir = hold_dir / "release"
|
|
517
|
+
release_dir.mkdir(parents=True, exist_ok=True)
|
|
518
|
+
(hold_dir / "ready").unlink(missing_ok=True)
|
|
519
|
+
(hold_dir / "stop").unlink(missing_ok=True)
|
|
520
|
+
for stale in release_dir.iterdir():
|
|
521
|
+
stale.unlink(missing_ok=True)
|
|
522
|
+
resolved = resolve_workspace(workspace)
|
|
523
|
+
os.environ["PORT_HOLD_WORKSPACE"] = resolved
|
|
524
|
+
os.environ["VOUCHA_PORT_HOLD_WORKSPACE"] = resolved
|
|
525
|
+
reap_stale_holder(workspace)
|
|
526
|
+
held = allocate_hold_sockets(count, max_bind_attempts, runner_slot)
|
|
527
|
+
ports = list(held)
|
|
528
|
+
if daemonize_holder():
|
|
529
|
+
holder_loop(held, hold_dir, workspace)
|
|
530
|
+
raise AssertionError("holder_loop returned")
|
|
531
|
+
for sockets in held.values():
|
|
532
|
+
for item in sockets:
|
|
533
|
+
item.close()
|
|
534
|
+
deadline = time.monotonic() + HOLD_READY_TIMEOUT_SECONDS
|
|
535
|
+
while time.monotonic() < deadline:
|
|
536
|
+
if (hold_dir / "ready").is_file():
|
|
537
|
+
return ports
|
|
538
|
+
time.sleep(HOLD_POLL_SECONDS)
|
|
539
|
+
reap_stale_holder(workspace)
|
|
540
|
+
raise RuntimeError(f"port holder did not become ready under {hold_dir}")
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def port_is_bindable(port: int) -> bool:
|
|
544
|
+
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
545
|
+
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 0)
|
|
546
|
+
try:
|
|
547
|
+
probe.bind(("127.0.0.1", port))
|
|
548
|
+
return True
|
|
549
|
+
except OSError:
|
|
550
|
+
return False
|
|
551
|
+
finally:
|
|
552
|
+
probe.close()
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def wait_until_port_bindable(port: int) -> None:
|
|
556
|
+
deadline = time.monotonic() + CONTROL_WAIT_SECONDS
|
|
557
|
+
while time.monotonic() < deadline:
|
|
558
|
+
if port_is_bindable(port):
|
|
559
|
+
return
|
|
560
|
+
time.sleep(HOLD_POLL_SECONDS)
|
|
561
|
+
raise RuntimeError(f"port {port} did not become bindable")
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def request_release(hold_dir: Path, port: int) -> None:
|
|
565
|
+
hold_dir = hold_dir.resolve()
|
|
566
|
+
release_path = hold_dir / "release" / str(port)
|
|
567
|
+
release_path.parent.mkdir(parents=True, exist_ok=True)
|
|
568
|
+
release_path.write_text("1")
|
|
569
|
+
deadline = time.monotonic() + CONTROL_WAIT_SECONDS
|
|
570
|
+
while time.monotonic() < deadline:
|
|
571
|
+
if port_is_bindable(port):
|
|
572
|
+
return
|
|
573
|
+
time.sleep(HOLD_POLL_SECONDS)
|
|
574
|
+
raise RuntimeError(f"port {port} was not released by the holder under {hold_dir}")
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def request_stop(hold_dir: Path, workspace: str) -> None:
|
|
578
|
+
hold_dir = hold_dir.resolve()
|
|
579
|
+
hold_dir.mkdir(parents=True, exist_ok=True)
|
|
580
|
+
ports_path = hold_dir / "ports"
|
|
581
|
+
remaining = (
|
|
582
|
+
[int(item) for item in ports_path.read_text().split() if item] if ports_path.is_file() else []
|
|
583
|
+
)
|
|
584
|
+
(hold_dir / "stop").write_text("1")
|
|
585
|
+
pid_path = hold_dir / "pid"
|
|
586
|
+
pid = read_pid(pid_path) if pid_path.is_file() else None
|
|
587
|
+
if pid is not None and pid_is_alive(pid) and is_our_holder(pid, workspace):
|
|
588
|
+
try:
|
|
589
|
+
os.kill(pid, signal.SIGTERM)
|
|
590
|
+
except ProcessLookupError:
|
|
591
|
+
pass
|
|
592
|
+
deadline = time.monotonic() + CONTROL_WAIT_SECONDS
|
|
593
|
+
while time.monotonic() < deadline:
|
|
594
|
+
current = read_pid(pid_path)
|
|
595
|
+
if current is None or not pid_is_alive(current):
|
|
596
|
+
break
|
|
597
|
+
time.sleep(HOLD_POLL_SECONDS)
|
|
598
|
+
for port in remaining:
|
|
599
|
+
wait_until_port_bindable(port)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def check_holder(hold_dir: Path, workspace: str) -> None:
|
|
603
|
+
hold_dir = hold_dir.resolve()
|
|
604
|
+
pid_path = hold_dir / "pid"
|
|
605
|
+
pid = read_pid(pid_path)
|
|
606
|
+
if pid is None:
|
|
607
|
+
raise RuntimeError(f"port holder pid file missing under {hold_dir}")
|
|
608
|
+
if not pid_is_alive(pid):
|
|
609
|
+
raise RuntimeError(f"port holder {pid} is not running")
|
|
610
|
+
if not is_our_holder(pid, workspace):
|
|
611
|
+
raise RuntimeError(f"pid {pid} is not the workspace port holder")
|
|
612
|
+
ports_path = hold_dir / "ports"
|
|
613
|
+
raw_ports = ports_path.read_text().split() if ports_path.is_file() else []
|
|
614
|
+
ports = [int(item) for item in raw_ports if item]
|
|
615
|
+
if not ports:
|
|
616
|
+
raise RuntimeError("port holder has no remaining reserved ports")
|
|
617
|
+
for port in ports:
|
|
618
|
+
if port_is_bindable(port):
|
|
619
|
+
raise RuntimeError(f"port {port} is bindable; holder is not reserving it")
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def resolve_runner_slot() -> int | None:
|
|
623
|
+
if os.environ.get("GITHUB_ACTIONS") != "true":
|
|
624
|
+
return None
|
|
625
|
+
return detect_runner_slot(os.environ.get("GITHUB_WORKSPACE", ""), os.getcwd())
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def main() -> int:
|
|
629
|
+
args = parse_args()
|
|
630
|
+
try:
|
|
631
|
+
hold_dir = args.hold_dir.resolve() if args.hold_dir is not None else None
|
|
632
|
+
workspace = resolve_workspace(args.workspace)
|
|
633
|
+
if args.release is not None:
|
|
634
|
+
request_release(hold_dir, args.release)
|
|
635
|
+
return 0
|
|
636
|
+
if args.stop:
|
|
637
|
+
request_stop(hold_dir, workspace)
|
|
638
|
+
return 0
|
|
639
|
+
if args.check:
|
|
640
|
+
check_holder(hold_dir, workspace)
|
|
641
|
+
return 0
|
|
642
|
+
runner_slot = resolve_runner_slot()
|
|
643
|
+
if args.hold:
|
|
644
|
+
ports = start_holder(
|
|
645
|
+
args.count, args.max_bind_attempts, runner_slot, hold_dir, workspace
|
|
646
|
+
)
|
|
647
|
+
else:
|
|
648
|
+
ports = allocate_ports(args.count, args.max_bind_attempts, runner_slot)
|
|
649
|
+
except Exception as error:
|
|
650
|
+
print(f"::error::{error}", file=sys.stderr)
|
|
651
|
+
return 1
|
|
652
|
+
|
|
653
|
+
print(" ".join(str(port) for port in ports))
|
|
654
|
+
return 0
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
if FETCH_FORBIDDEN_PORTS_PATH.is_file() and RUNNER_PORT_POLICY_PATH.is_file():
|
|
658
|
+
configure_policy(RUNNER_PORT_POLICY_PATH, FETCH_FORBIDDEN_PORTS_PATH)
|
|
659
|
+
|
|
660
|
+
if __name__ == "__main__":
|
|
661
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
[
|
|
2
|
+
1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, 87, 95, 101, 102,
|
|
3
|
+
103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 139, 143, 161, 179, 389, 427, 465,
|
|
4
|
+
512, 513, 514, 515, 526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601, 636, 989, 990, 993,
|
|
5
|
+
995, 1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668,
|
|
6
|
+
6669, 6679, 6697, 10080
|
|
7
|
+
]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
label="${1:-required jobs}"
|
|
5
|
+
results="${RESULTS:-}"
|
|
6
|
+
|
|
7
|
+
if [ -z "$results" ]; then
|
|
8
|
+
echo "::error::RESULTS is required"
|
|
9
|
+
exit 1
|
|
10
|
+
fi
|
|
11
|
+
|
|
12
|
+
echo "Job results: $results"
|
|
13
|
+
|
|
14
|
+
if echo "$results" | grep -qE '"result":\s*"(failure|cancelled)"'; then
|
|
15
|
+
echo "One or more ${label} failed or were cancelled"
|
|
16
|
+
exit 1
|
|
17
|
+
fi
|
|
18
|
+
|
|
19
|
+
echo "All ${label} passed or were skipped"
|