diff --git a/docker/job-started-hook.sh b/docker/job-started-hook.sh index 8b7d8bc..d124126 100644 --- a/docker/job-started-hook.sh +++ b/docker/job-started-hook.sh @@ -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 @@ -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 @@ -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 diff --git a/package.json b/package.json index e846a13..646e52a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tests/job-started-hook.test.ts b/tests/job-started-hook.test.ts index 863634f..78b6407 100644 --- a/tests/job-started-hook.test.ts +++ b/tests/job-started-hook.test.ts @@ -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);