Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions docker/job-started-hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ assignment_max_attempts=${CF_RUNNER_CACHE_ASSIGNMENT_MAX_ATTEMPTS:-30}
case "$assignment_max_attempts" in
''|*[!0-9]*) assignment_max_attempts=30 ;;
esac
while [ "${assignment_max_attempts#0}" != "$assignment_max_attempts" ]; do
assignment_max_attempts=${assignment_max_attempts#0}
done
if [ -z "$assignment_max_attempts" ]; then
assignment_max_attempts=0
fi
if [ "$assignment_max_attempts" -lt 1 ]; then
assignment_max_attempts=30
fi
Expand All @@ -36,9 +42,23 @@ case "$assignment_poll_seconds" in
*.*.*|.*) assignment_poll_seconds=1 ;;
esac

status=000
assignment_deadline_epoch=$(($(date +%s) + assignment_max_attempts))
attempt=1
while [ "$attempt" -le "$assignment_max_attempts" ]; do
status=$(curl --silent --output /dev/null --write-out '%{http_code}' --max-time 5 \
remaining_seconds=$((assignment_deadline_epoch - $(date +%s)))
if [ "$remaining_seconds" -le 0 ]; then
if [ "$attempt" -eq 1 ]; then
remaining_seconds=1
else
break
fi
fi
request_timeout_seconds=5
if [ "$remaining_seconds" -lt "$request_timeout_seconds" ]; then
request_timeout_seconds=$remaining_seconds
fi
status=$(curl --silent --output /dev/null --write-out '%{http_code}' --max-time "$request_timeout_seconds" \
--header "Authorization: $cache_authorization" "$assignment_endpoint" || true)
if [ "$status" = '200' ]; then
exit 0
Expand All @@ -50,14 +70,20 @@ while [ "$attempt" -le "$assignment_max_attempts" ]; do
# poll before its authorization is visible and see 401. 000 is curl's
# output for a connection failure. Keep polling inside the existing
# bounded window instead of failing the job on the first sample.
000|401|408|429|500|502|503|504) ;;
000|401|408|429|5??) ;;
*)
printf '%s\n' "::error title=Cloudflare runner cache assignment::The Worker returned HTTP $status while waiting for GitHub's runner assignment."
exit 1
;;
esac
attempt=$((attempt + 1))
sleep "$assignment_poll_seconds"
remaining_seconds=$((assignment_deadline_epoch - $(date +%s)))
if [ "$remaining_seconds" -le 0 ]; then
break
fi
sleep_seconds=$(awk -v poll="$assignment_poll_seconds" -v remaining="$remaining_seconds" \
'BEGIN { print (poll < remaining ? poll : remaining) }')
sleep "$sleep_seconds"
done

# Still fail closed. The job must not run with a cache capability whose
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "cloudflare-github-actions-runner",
"version": "1.0.9",
"version": "1.0.10",
"description": "Run GitHub Actions jobs as disposable Cloudflare Containers.",
"keywords": [
"ci",
Expand Down
91 changes: 91 additions & 0 deletions tests/job-started-hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,97 @@ describe("runner cache assignment hook", () => {
}
});

it("keeps polling through a transient Cloudflare edge failure", async () => {
let attempts = 0;
const server = createServer((_request, response) => {
attempts += 1;
response.writeHead(attempts === 1 ? 522 : 200).end();
});
const port = await listen(server);
const directory = await mkdtemp(join(tmpdir(), "runner-job-hook-test-"));
const configurationPath = join(directory, "cache-assignment");
await writeFile(configurationPath, `http://127.0.0.1:${port}/v1/runner-cache\nBearer runner-capability\n`, {
mode: 0o600,
});

try {
await expect(
runHook(configurationPath, {
CF_RUNNER_CACHE_ASSIGNMENT_MAX_ATTEMPTS: "3",
CF_RUNNER_CACHE_ASSIGNMENT_POLL_SECONDS: "0",
}),
).resolves.toEqual({ code: 0, stdout: "", stderr: "" });
expect(attempts).toBe(2);
await expect(readFile(configurationPath)).rejects.toMatchObject({ code: "ENOENT" });
} finally {
await close(server);
await rm(directory, { recursive: true, force: true });
}
});

it("bounds a hanging connection by the configured wait window", async () => {
let attempts = 0;
const server = createServer(() => {
attempts += 1;
});
const port = await listen(server);
const directory = await mkdtemp(join(tmpdir(), "runner-job-hook-test-"));
const configurationPath = join(directory, "cache-assignment");
await writeFile(configurationPath, `http://127.0.0.1:${port}/v1/runner-cache\nBearer runner-capability\n`, {
mode: 0o600,
});

try {
const startedAt = Date.now();
await expect(
runHook(configurationPath, {
CF_RUNNER_CACHE_ASSIGNMENT_MAX_ATTEMPTS: "1",
CF_RUNNER_CACHE_ASSIGNMENT_POLL_SECONDS: "0",
}),
).resolves.toEqual({
code: 1,
stdout:
"::error title=Cloudflare runner cache assignment::GitHub's runner assignment was not observed within 1 seconds (last Worker status: 000).\n",
stderr: "",
});
expect(Date.now() - startedAt).toBeLessThan(3_000);
expect(attempts).toBe(1);
await expect(readFile(configurationPath)).rejects.toMatchObject({ code: "ENOENT" });
} finally {
server.closeAllConnections();
await close(server);
await rm(directory, { recursive: true, force: true });
}
}, 10_000);

it("normalizes a leading-zero attempt count before deadline arithmetic", async () => {
let attempts = 0;
const server = createServer((_request, response) => {
attempts += 1;
response.writeHead(200).end();
});
const port = await listen(server);
const directory = await mkdtemp(join(tmpdir(), "runner-job-hook-test-"));
const configurationPath = join(directory, "cache-assignment");
await writeFile(configurationPath, `http://127.0.0.1:${port}/v1/runner-cache\nBearer runner-capability\n`, {
mode: 0o600,
});

try {
await expect(
runHook(configurationPath, {
CF_RUNNER_CACHE_ASSIGNMENT_MAX_ATTEMPTS: "08",
CF_RUNNER_CACHE_ASSIGNMENT_POLL_SECONDS: "0",
}),
).resolves.toEqual({ code: 0, stdout: "", stderr: "" });
expect(attempts).toBe(1);
await expect(readFile(configurationPath)).rejects.toMatchObject({ code: "ENOENT" });
} finally {
await close(server);
await rm(directory, { recursive: true, force: true });
}
});

it("still fails closed when the authorization never becomes visible", async () => {
const server = createServer((_request, response) => response.writeHead(401).end());
const port = await listen(server);
Expand Down