diff --git a/package.json b/package.json index ca54117..2be3505 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cloudflare-github-actions-runner", - "version": "1.0.8", + "version": "1.0.9", "description": "Run GitHub Actions jobs as disposable Cloudflare Containers.", "keywords": [ "ci", diff --git a/scripts/setup.ts b/scripts/setup.ts index 35e79ce..e9f3155 100644 --- a/scripts/setup.ts +++ b/scripts/setup.ts @@ -52,6 +52,19 @@ function colorStatus(color, status) { return `${terminalColor[color]}${status}${terminalColor.reset}`; } +export function formatSetupStepDuration(durationMs) { + const roundedDurationMs = Math.max(0, Math.round(durationMs)); + if (roundedDurationMs < 1_000) { + return `${roundedDurationMs}ms`; + } + if (roundedDurationMs < 60_000) { + return `${(roundedDurationMs / 1_000).toFixed(1)}s`; + } + const minutes = Math.floor(roundedDurationMs / 60_000); + const seconds = Math.floor((roundedDurationMs % 60_000) / 1_000); + return `${minutes}m ${seconds}s`; +} + async function withSpinner(checkingStatus, completeStatus, operation) { let frame = 0; let timer; @@ -81,10 +94,21 @@ async function withSpinner(checkingStatus, completeStatus, operation) { render(); } }; + const runStep = async (status, completedStatus, step) => { + updateStatus(status); + const startedAt = Date.now(); + const result = await step(); + pause(); + process.stdout.write( + `${colorStatus("green", `✔ ${completedStatus} (${formatSetupStepDuration(Date.now() - startedAt)})`)}\n`, + ); + resume(); + return result; + }; resume(); try { - const result = await operation({ pause, resume, updateStatus }); + const result = await operation({ pause, resume, runStep, updateStatus }); pause(); const status = completeStatus instanceof Function ? completeStatus(result) : { message: completeStatus }; process.stdout.write(`${colorStatus(status.color ?? "green", `${status.marker ?? "✔"} ${status.message}`)}\n`); @@ -330,6 +354,24 @@ export function hasValidRunnerSetupTokenStatus(status) { ); } +/** Non-sensitive credential check results shown before setup changes any stored credentials. */ +export function existingWorkerTokenStatusMessages(status) { + const cloudflareTokenValid = + status.cloudflareContainersToken && status.cloudflareRegistryPush && status.cloudflareResourceTagging; + const githubAppValid = status.githubApp && status.githubAppWebhookSecret; + return [ + `${cloudflareTokenValid ? "✔" : "✘"} Cloudflare Containers Write + Tag Read/Write token: ${cloudflareTokenValid ? "valid (reusing)" : "needs attention"}`, + `${githubAppValid ? "✔" : "✘"} GitHub App credentials: ${githubAppValid ? "valid (reusing)" : "unavailable or rejected"}`, + `${status.resourceTraceSigningKey ? "✔" : "✘"} Runner resource-trace signing key: ${status.resourceTraceSigningKey ? "present (reusing)" : "missing"}`, + `${status.runnerCacheSigningKey ? "✔" : "✘"} Runner R2-cache signing key: ${status.runnerCacheSigningKey ? "present (reusing)" : "missing"}`, + ]; +} + +/** Keep discoverable App credentials until the user explicitly chooses a replacement. */ +export function shouldCreateInitialGitHubApp(existingTokens, existingGitHubAppConfiguration) { + return !(existingTokens.githubApp && existingTokens.githubAppWebhookSecret) && !existingGitHubAppConfiguration; +} + export function githubAppManifest(name, workerBaseUrl, redirectUrl) { return { name, @@ -1394,6 +1436,8 @@ export function remoteRunnerImageBuildProgressMessage(status) { const phase = status?.progress?.phase; const phases = { queued: "Waiting for Cloudflare to schedule the image build", + "bootstrapping-builder": "Bootstrapping Cloudflare's private daemonless image builder", + "rolling-out-builder": "Rolling Cloudflare's private daemonless image builder to its private image", "downloading-source": "Downloading the runner-image source from GitHub", "starting-builder": "Starting Cloudflare's isolated daemonless image builder", "preparing-build-context": "Preparing the runner image build context", @@ -1403,7 +1447,19 @@ export function remoteRunnerImageBuildProgressMessage(status) { }; const parsedPhase = z.enum(Object.keys(phases)).safeParse(phase); if (parsedPhase.success) { - return phases[parsedPhase.data]; + const message = phases[parsedPhase.data]; + if (parsedPhase.data !== "rolling-out") { + return message; + } + const rollout = z + .object({ + processedApplications: z.number().int().nonnegative(), + totalApplications: z.number().int().nonnegative(), + }) + .safeParse(status?.progress?.rollout); + return rollout.success + ? `${message} (${rollout.data.processedApplications}/${rollout.data.totalApplications} profiles checked)` + : message; } if (status?.status === "queued") { return phases.queued; @@ -1793,51 +1849,79 @@ export async function main() { }); const setupValidationToken = generateWebhookSecret(); + const existingGitHubAppConfiguration = cloudflareAccount.runnerPool.githubAppConfigured; const existingTokens = await retryWorkerValidationAuthorization(() => - withSpinner("Checking existing Worker token configuration", "Worker token configuration: checked", async () => { - await putWorkerSecret("CLOUDFLARE_ACCOUNT_ID", cloudflareAccount.account.id, cloudflareEnvironment); - await putWorkerSecret("RUNNER_SETUP_VALIDATION_TOKEN", setupValidationToken, cloudflareEnvironment); - return retryWorkerTokenValidation(() => validateExistingWorkerTokens(workerBaseUrl, setupValidationToken)); - }), + withSpinner( + "Checking existing Worker token configuration", + "Worker token configuration: checked", + async ({ runStep }) => { + await runStep( + "Saving the selected Cloudflare account for the Worker", + "Selected Cloudflare account saved for the Worker", + () => putWorkerSecret("CLOUDFLARE_ACCOUNT_ID", cloudflareAccount.account.id, cloudflareEnvironment), + ); + await runStep( + "Authorizing this setup session with the Worker", + "Setup session authorized with the Worker", + () => putWorkerSecret("RUNNER_SETUP_VALIDATION_TOKEN", setupValidationToken, cloudflareEnvironment), + ); + return runStep( + "Validating the Worker's existing Cloudflare and GitHub App credentials", + "Existing Worker credentials validated", + () => + retryWorkerTokenValidation(() => validateExistingWorkerTokens(workerBaseUrl, setupValidationToken), { + // An existing App can take a short time to become visible after the + // deployment that introduced this setup session. Do not create a + // duplicate App merely because GitHub has not accepted its JWT yet. + isValid: existingGitHubAppConfiguration + ? (status) => status.githubApp && status.githubAppWebhookSecret + : () => true, + }), + ); + }, + ), ); if (existingTokens === undefined) { await deleteWorkerSecret("RUNNER_SETUP_VALIDATION_TOKEN", cloudflareEnvironment); console.log("Setup stopped. The temporary setup credential was removed."); return; } + console.log("\nExisting Worker credential status:"); + for (const message of existingWorkerTokenStatusMessages(existingTokens)) { + console.log(` ${message}`); + } let discardUninstalledInitialGitHubAppCredentials = false; try { let cloudflareToken; - if ( + const existingCloudflareTokenIsValid = existingTokens.cloudflareContainersToken && existingTokens.cloudflareRegistryPush && - existingTokens.cloudflareResourceTagging - ) { - console.log(" \u2714 Reusing the valid account-owned Cloudflare Containers Write + Tag Read/Write token"); - } else { + existingTokens.cloudflareResourceTagging; + if (!existingCloudflareTokenIsValid) { cloudflareToken = await promptForValidatedCloudflareToken(cloudflareAccount.account, { showTokenForm: true }); await putWorkerSecret("CLOUDFLARE_CONTAINERS_API_TOKEN", cloudflareToken, cloudflareEnvironment); } let createdGitHubApp; let replacementGitHubAppIsPending = false; - if (existingTokens.githubApp && existingTokens.githubAppWebhookSecret) { - console.log(" \u2714 Reusing the valid GitHub App credentials"); - } else { - createdGitHubApp = await createGitHubApp(); - await storeGitHubAppCredentials(createdGitHubApp, cloudflareEnvironment); - discardUninstalledInitialGitHubAppCredentials = true; + const existingGitHubAppIsValid = existingTokens.githubApp && existingTokens.githubAppWebhookSecret; + if (!existingGitHubAppIsValid) { + if (!shouldCreateInitialGitHubApp(existingTokens, existingGitHubAppConfiguration)) { + console.log( + " ! Found an existing GitHub App configuration, but GitHub did not validate it. Keeping it until you choose recovery.", + ); + } else { + createdGitHubApp = await createGitHubApp(); + await storeGitHubAppCredentials(createdGitHubApp, cloudflareEnvironment); + discardUninstalledInitialGitHubAppCredentials = true; + } } - if (existingTokens.resourceTraceSigningKey) { - console.log(" ✔ Reusing the runner resource-trace signing key"); - } else { + if (!existingTokens.resourceTraceSigningKey) { await putWorkerSecret("RESOURCE_TRACE_SIGNING_KEY", generateResourceTraceSigningKey(), cloudflareEnvironment); } - if (existingTokens.runnerCacheSigningKey) { - console.log(" ✔ Reusing the runner R2-cache signing key"); - } else { + if (!existingTokens.runnerCacheSigningKey) { await putWorkerSecret("RUNNER_CACHE_SIGNING_KEY", generateResourceTraceSigningKey(), cloudflareEnvironment); } diff --git a/src/cloudflare-containers.ts b/src/cloudflare-containers.ts index fcab6bf..b5506ce 100644 --- a/src/cloudflare-containers.ts +++ b/src/cloudflare-containers.ts @@ -92,6 +92,13 @@ export interface RolloutRunnerApplicationImagesOptions { * recent PATCH reached its own rollout (for example A→B→A). */ reissueMatchingImageRollouts?: boolean; + /** Reports how many runner applications this rollout pass has inspected. */ + onProgress?: (progress: RunnerApplicationImageRolloutProgress) => Promise; +} + +export interface RunnerApplicationImageRolloutProgress { + processedApplications: number; + totalApplications: number; } export interface RolloutRunnerImageBuilderOptions { @@ -450,60 +457,78 @@ export async function rolloutRunnerApplicationImages( ); const updatedApplications: string[] = []; const skippedApplications: string[] = []; - - for (const listedApplication of applications) { - // The scheduler can resize an idle custom application while an image build - // is finishing. Re-read its configuration immediately before patching so - // an image rollout never writes the stale resources from the initial list. - // eslint-disable-next-line no-await-in-loop -- obtain the current configuration for this exact application. - const application = await findRunnerApplication(env, listedApplication.name, dependencies); - // eslint-disable-next-line no-await-in-loop -- a configuration rollout must be observed before the next application. - const rollouts = await listRollouts(env, application.id, dependencies); - if (rollouts.some((rollout) => rollout.status === "pending" || rollout.status === "progressing")) { - skippedApplications.push(application.name); - continue; + const reportProgress = async (progress: RunnerApplicationImageRolloutProgress): Promise => { + try { + await options.onProgress?.(progress); + } catch (error) { + // Progress is telemetry for the setup UI, not part of the Container API + // transaction. A transient Durable Object error must not repeat a + // successful rollout or abandon the build lease. + console.error("Cloudflare runner image rollout progress reporting failed", { + error: error instanceof Error ? error.message : String(error), + }); } - if ( - !options.reissueMatchingImageRollouts && - application.configuration.image === image && - rollouts.some((rollout) => rollout.status === "completed" && rollout.target_configuration.image === image) - ) { - continue; - } - if (applicationHasLiveInstances(application)) { - skippedApplications.push(application.name); - continue; - } - if (application.configuration.image !== image) { - // eslint-disable-next-line no-await-in-loop -- preserve capacity by finishing one image patch before the next. - await patchApplicationImage(env, application, image, dependencies); - } - // A scheduler admission may have resized this custom application while - // the image-only PATCH was in flight. Read its current resources again so - // the full rollout target cannot restore that stale machine shape. - // eslint-disable-next-line no-await-in-loop -- this exact application owns its next rollout body. - const current = await findRunnerApplication(env, application.name, dependencies); - if (applicationHasLiveInstances(current)) { - skippedApplications.push(application.name); - continue; + }; + await reportProgress({ processedApplications: 0, totalApplications: applications.length }); + + for (const [index, listedApplication] of applications.entries()) { + try { + // The scheduler can resize an idle custom application while an image build + // is finishing. Re-read its configuration immediately before patching so + // an image rollout never writes the stale resources from the initial list. + // eslint-disable-next-line no-await-in-loop -- obtain the current configuration for this exact application. + const application = await findRunnerApplication(env, listedApplication.name, dependencies); + // eslint-disable-next-line no-await-in-loop -- a configuration rollout must be observed before the next application. + const rollouts = await listRollouts(env, application.id, dependencies); + if (rollouts.some((rollout) => rollout.status === "pending" || rollout.status === "progressing")) { + skippedApplications.push(application.name); + continue; + } + if ( + !options.reissueMatchingImageRollouts && + application.configuration.image === image && + rollouts.some((rollout) => rollout.status === "completed" && rollout.target_configuration.image === image) + ) { + continue; + } + if (applicationHasLiveInstances(application)) { + skippedApplications.push(application.name); + continue; + } + if (application.configuration.image !== image) { + // eslint-disable-next-line no-await-in-loop -- preserve capacity by finishing one image patch before the next. + await patchApplicationImage(env, application, image, dependencies); + } + // A scheduler admission may have resized this custom application while + // the image-only PATCH was in flight. Read its current resources again so + // the full rollout target cannot restore that stale machine shape. + // eslint-disable-next-line no-await-in-loop -- this exact application owns its next rollout body. + const current = await findRunnerApplication(env, application.name, dependencies); + if (applicationHasLiveInstances(current)) { + skippedApplications.push(application.name); + continue; + } + // PATCH acceptance does not make the requested image visible in the + // application's active configuration until a rollout applies it. Build + // the rollout target from this freshly read configuration so concurrent + // scheduler-owned resource changes survive while the new image remains + // explicit below. + // If a prior PATCH made it through but its rollout call did not, the + // application already has this image here. Still create the rollout: a + // desired configuration alone does not prove live instances moved. + // eslint-disable-next-line no-await-in-loop -- each explicit rollout belongs to this application. + await createApplicationImageRollout( + env, + current, + image, + "Cloudflare GitHub Actions runner image update", + dependencies, + ); + updatedApplications.push(application.name); + } finally { + // eslint-disable-next-line no-await-in-loop -- progress must correspond to the application that just finished inspection. + await reportProgress({ processedApplications: index + 1, totalApplications: applications.length }); } - // PATCH acceptance does not make the requested image visible in the - // application's active configuration until a rollout applies it. Build - // the rollout target from this freshly read configuration so concurrent - // scheduler-owned resource changes survive while the new image remains - // explicit below. - // If a prior PATCH made it through but its rollout call did not, the - // application already has this image here. Still create the rollout: a - // desired configuration alone does not prove live instances moved. - // eslint-disable-next-line no-await-in-loop -- each explicit rollout belongs to this application. - await createApplicationImageRollout( - env, - current, - image, - "Cloudflare GitHub Actions runner image update", - dependencies, - ); - updatedApplications.push(application.name); } return { updatedApplications, skippedApplications }; } diff --git a/src/runner-image-build-workflow.ts b/src/runner-image-build-workflow.ts index 017162d..ff0b7b5 100644 --- a/src/runner-image-build-workflow.ts +++ b/src/runner-image-build-workflow.ts @@ -23,6 +23,7 @@ import { import { runnerImageBuilderExitError } from "./runner-image-builder-command"; import { runnerImageBuilderProtocolVersion, + runnerImageBuildPhaseForOwner, type RunnerImageBuildResult, type RunnerImageBuildStatus, } from "./runner-image-builder"; @@ -306,10 +307,6 @@ export class RunnerImageBuildWorkflow extends WorkflowEntrypoint< } let completed: RunnerImageBuildResult | undefined; for (let buildRound = 1; buildRound <= maximumBuildQueueRounds; buildRound += 1) { - // eslint-disable-next-line no-await-in-loop -- each queued source is staged only after its predecessor completes. - await withFreshRunnerImageBuilder(builderNamespace, (builder) => - builder.updateBuildProgress(event.payload.workflowId, "downloading-source"), - ); // A joining Workflow must wait for its predecessor, then stage its own source in a fresh slot. // eslint-disable-next-line no-await-in-loop -- the durable step serializes this exact queue position. ownsBuild = await step.do(`start runner image build in Cloudflare ${buildRound}`, buildStep, async () => { @@ -341,6 +338,10 @@ export class RunnerImageBuildWorkflow extends WorkflowEntrypoint< if (checked.kind === "failed") { return { kind: "failed", exitCode: checked.exitCode, diagnostic: checked.diagnostic }; } + const phase = runnerImageBuildPhaseForOwner(ownsBuild, checked); + if (phase !== undefined) { + await builder.updateBuildProgress(event.payload.workflowId, phase); + } return { kind: "running" }; }), ); @@ -402,6 +403,8 @@ export class RunnerImageBuildWorkflow extends WorkflowEntrypoint< await builder.updateBuildProgress(event.payload.workflowId, "rolling-out"); const result = await rolloutRunnerApplicationImages(this.env, completed.imageReference, undefined, { reissueMatchingImageRollouts: lease.reissueMatchingImageRollouts, + onProgress: (progress) => + builder.updateBuildProgress(event.payload.workflowId, "rolling-out", progress), }); await builder.completeRollOutAttempt(event.payload.workflowId, completed.imageReference); // Keep the lease while busy applications are pending. The next diff --git a/src/runner-image-builder-command.ts b/src/runner-image-builder-command.ts index 75d44e6..d2daa58 100644 --- a/src/runner-image-builder-command.ts +++ b/src/runner-image-builder-command.ts @@ -11,6 +11,7 @@ export function runnerImageBuilderEntrypoint(): string[] { export const runnerImageBuilderWorkspace = "/tmp/cloudflare-runner-workspace"; export const runnerImageBuilderExitStatusPath = `${runnerImageBuilderWorkspace}/build.exit`; export const runnerImageBuilderLogPath = `${runnerImageBuilderWorkspace}/build.log`; +export const runnerImageBuilderProgressPath = `${runnerImageBuilderWorkspace}/build.phase`; export const runnerImageBuilderBusyboxPath = `${runnerImageBuilderWorkspace}/busybox`; export const runnerImageBuilderResultPath = `${runnerImageBuilderWorkspace}/build.result`; export const runnerImageBuilderBuiltPath = `${runnerImageBuilderWorkspace}/build.built`; @@ -32,9 +33,11 @@ export function runnerImageBuilderCommand(): string { const command = kanikoCommand(); const buildAction = [ "build_runner_image() {", + 'printf "%s" checking-image-cache > "$workspace/build.phase"', 'if /busybox/wget -q --spider "$RUNNER_IMAGE_REGISTRY_MANIFEST_URL/runner-$source_digest"; then', ' printf "%s" false > "$workspace/build.built"', "else", + ' printf "%s" building-and-pushing > "$workspace/build.phase"', ` ${command} || return 13`, ' printf "%s" true > "$workspace/build.built"', "fi", diff --git a/src/runner-image-builder.ts b/src/runner-image-builder.ts index 782b4d0..6d2178f 100644 --- a/src/runner-image-builder.ts +++ b/src/runner-image-builder.ts @@ -13,6 +13,7 @@ import { runnerImageBuilderExitError, runnerImageBuilderExitStatusPath, runnerImageBuilderLogPath, + runnerImageBuilderProgressPath, runnerImageBuilderResultPath, } from "./runner-image-builder-command"; import { @@ -29,7 +30,7 @@ export interface RunnerImageBuildResult { } export type RunnerImageBuildStatus = - | { kind: "running" } + | { kind: "running"; phase?: RunnerImageBuildPhase } | { kind: "completed"; result: RunnerImageBuildResult } | { kind: "failed"; exitCode: number; diagnostic?: string }; @@ -64,12 +65,33 @@ export const runnerImageBuildPhases = [ export type RunnerImageBuildPhase = (typeof runnerImageBuildPhases)[number]; +/** A joining Workflow may observe the owner's build, but must not overwrite its progress record. */ +export function runnerImageBuildPhaseForOwner( + ownsBuild: boolean, + status: RunnerImageBuildStatus, +): RunnerImageBuildPhase | undefined { + return ownsBuild && status.kind === "running" ? status.phase : undefined; +} + +/** Accept both the current prefixed status and the bare exit code used by older detached builders. */ +export function runnerImageBuilderPolledExitCode(status: string): number | undefined { + const value = status.startsWith("exited:") ? status.slice("exited:".length) : status; + return /^\d+$/u.test(value) ? runnerImageBuilderExitCode(Number(value)) : undefined; +} + export interface RunnerImageBuildProgress { workflowId: string; phase: RunnerImageBuildPhase; + rollout?: RunnerImageBuildRolloutProgress; updatedAt: string; } +/** Real-time application counts are available only while rolling out a completed image. */ +export interface RunnerImageBuildRolloutProgress { + processedApplications: number; + totalApplications: number; +} + interface ActiveRunnerImageBuild { workflowId: string; sourceArchiveKey: string; @@ -195,8 +217,29 @@ export class RunnerImageBuilder extends Container { return runnerImageBuilderProtocolVersion; } - async updateBuildProgress(workflowId: string, phase: RunnerImageBuildPhase): Promise { - await this.ctx.storage.put(runnerImageBuildProgressKey, { workflowId, phase, updatedAt: new Date().toISOString() }); + async updateBuildProgress( + workflowId: string, + phase: RunnerImageBuildPhase, + rollout?: RunnerImageBuildRolloutProgress, + ): Promise { + const current = await this.ctx.storage.get(runnerImageBuildProgressKey); + if ( + current?.workflowId === workflowId && + current.phase === phase && + current.rollout?.processedApplications === rollout?.processedApplications && + current.rollout?.totalApplications === rollout?.totalApplications + ) { + return; + } + const next: RunnerImageBuildProgress = { + workflowId, + phase, + updatedAt: new Date().toISOString(), + }; + if (rollout !== undefined) { + next.rollout = rollout; + } + await this.ctx.storage.put(runnerImageBuildProgressKey, next); } async buildProgress(workflowId: string): Promise { @@ -417,6 +460,7 @@ export class RunnerImageBuilder extends Container { }); try { + await this.updateBuildProgress(workflowId, "downloading-source"); const archive = await githubRepositoryArchiveWithMetadata(this.env, source.repository, source.ref); if (archive === undefined) { throw new Error("Cloudflare image builder could not download the configured source archive"); @@ -433,6 +477,7 @@ export class RunnerImageBuilder extends Container { }); throw new Error("Cloudflare image builder could not start its daemonless command host", { cause: error }); } + await this.updateBuildProgress(workflowId, "preparing-build-context"); const command = await container.exec(["/busybox/sh", "-c", runnerImageBuilderCommand()], { env: { RUNNER_IMAGE_SOURCE_URL: runnerImageSourceUrl, @@ -451,7 +496,6 @@ export class RunnerImageBuilder extends Container { } throw new Error(runnerImageBuilderExitError(runnerImageBuilderExitCode(output.exitCode))); } - await this.updateBuildProgress(workflowId, "building-and-pushing"); return { owner: true }; } catch (error) { const released = await this.releaseBuild(workflowId, sourceArchiveKey, true); @@ -628,7 +672,7 @@ export class RunnerImageBuilder extends Container { runnerImageBuilderBusyboxPath, "sh", "-c", - `if [ -f "${runnerImageBuilderExitStatusPath}" ]; then read exit_code < "${runnerImageBuilderExitStatusPath}"; printf "%s" "$exit_code"; else printf running; fi`, + `if [ -f "${runnerImageBuilderExitStatusPath}" ]; then read exit_code < "${runnerImageBuilderExitStatusPath}"; printf "exited:%s" "$exit_code"; elif [ -f "${runnerImageBuilderProgressPath}" ]; then read phase < "${runnerImageBuilderProgressPath}"; printf "phase:%s" "$phase"; else printf running; fi`, ], { stdout: "pipe", stderr: "ignore" }, ); @@ -636,7 +680,17 @@ export class RunnerImageBuilder extends Container { if (status === "running") { return { kind: "running" }; } - exitCode = runnerImageBuilderExitCode(Number(status)); + if (status.startsWith("phase:")) { + const phase = runnerImageBuildPhases.find((candidate) => candidate === status.slice("phase:".length)); + return phase === "checking-image-cache" || phase === "building-and-pushing" + ? { kind: "running", phase } + : { kind: "running" }; + } + const polledExitCode = runnerImageBuilderPolledExitCode(status); + if (polledExitCode === undefined) { + return { kind: "running" }; + } + exitCode = polledExitCode; } catch (error) { console.error("Cloudflare image builder could not poll its detached build", { error: error instanceof Error ? error.message : String(error), diff --git a/tests/cloudflare-containers.test.ts b/tests/cloudflare-containers.test.ts index 6fe31c2..cd7ea24 100644 --- a/tests/cloudflare-containers.test.ts +++ b/tests/cloudflare-containers.test.ts @@ -349,9 +349,12 @@ describe("Cloudflare custom Container configuration", () => { response([idle]), response(rollout("completed", resources)), ); + const progress = vi.fn<(status: { processedApplications: number; totalApplications: number }) => Promise>( + async () => undefined, + ); await expect( - rolloutRunnerApplicationImages(env, "registry.cloudflare.com/account/runner:new", deps), + rolloutRunnerApplicationImages(env, "registry.cloudflare.com/account/runner:new", deps, { onProgress: progress }), ).resolves.toEqual({ updatedApplications: ["runner-standard-3"], skippedApplications: [], @@ -367,6 +370,39 @@ describe("Cloudflare custom Container configuration", () => { expect(JSON.parse(String(deps.fetch.mock.calls[5]?.[1]?.body))).toMatchObject({ target_configuration: { image: "registry.cloudflare.com/account/runner:new" }, }); + expect(progress).toHaveBeenNthCalledWith(1, { processedApplications: 0, totalApplications: 1 }); + expect(progress).toHaveBeenNthCalledWith(2, { processedApplications: 1, totalApplications: 1 }); + }); + + it("does not fail an image rollout when setup progress reporting is unavailable", async () => { + const idle = { + ...application(resources, "runner-standard-3"), + health: { instances: { active: 0, assigned: 0, starting: 0, scheduling: 0 } }, + }; + const deps = dependencies( + response([idle]), + response([idle]), + response([]), + response(idle), + response([idle]), + response(rollout("completed", resources)), + ); + const progress = vi.fn<() => Promise>(async () => { + throw new Error("Durable Object temporarily unavailable"); + }); + const progressError = vi.spyOn(console, "error").mockImplementation(() => undefined); + + await expect( + rolloutRunnerApplicationImages(env, "registry.cloudflare.com/account/runner:new", deps, { onProgress: progress }), + ).resolves.toEqual({ + updatedApplications: ["runner-standard-3"], + skippedApplications: [], + }); + expect(progress).toHaveBeenCalledTimes(2); + expect(progressError).toHaveBeenCalledWith("Cloudflare runner image rollout progress reporting failed", { + error: "Durable Object temporarily unavailable", + }); + progressError.mockRestore(); }); it("uses the current custom-machine resources rather than the stale application list during an image rollout", async () => { diff --git a/tests/runner-image-builder.test.ts b/tests/runner-image-builder.test.ts index 96863e5..62e6368 100644 --- a/tests/runner-image-builder.test.ts +++ b/tests/runner-image-builder.test.ts @@ -5,7 +5,11 @@ import { describe, expect, it, vi } from "vite-plus/test"; vi.mock("@cloudflare/containers", () => ({ Container: class {} })); import { runOneShotRunnerImageBuilder } from "../src/one-shot-runner-image-builder"; -import { RunnerImageBuilder } from "../src/runner-image-builder"; +import { + runnerImageBuilderPolledExitCode, + runnerImageBuildPhaseForOwner, + RunnerImageBuilder, +} from "../src/runner-image-builder"; import { runnerImageBuilderCommand, runnerImageBuilderEntrypoint, @@ -180,6 +184,8 @@ describe("one-shot Cloudflare runner-image builder", () => { expect(runnerImageBuilderEntrypoint()).toEqual(["/busybox/sh", "-c", "exec sleep 2147483647"]); expect(script).toContain("/kaniko/executor --force"); + expect(script).toContain('printf "%s" checking-image-cache > "$workspace/build.phase"'); + expect(script).toContain('printf "%s" building-and-pushing > "$workspace/build.phase"'); expect(script).toContain("--custom-platform linux/amd64"); expect(script).toContain('--destination "$RUNNER_IMAGE_REFERENCE"'); expect(script).toContain("--insecure-registry registry.cloudflare.com"); @@ -203,6 +209,29 @@ describe("one-shot Cloudflare runner-image builder", () => { expect(spawnSync("sh", ["-n"], { input: script }).status).toBe(0); }); + it("reports the live image-build phase written by the detached command", async () => { + // SAFETY: This test supplies every field buildStatus reads and invokes no Container constructor behavior. + const builder = Object.assign(Object.create(RunnerImageBuilder.prototype), { + ctx: { + storage: { + get: async () => ({ + workflowId: "workflow", + sourceArchiveKey: "runner-image-source/workflow.tar.gz", + state: "active", + leaseExpiresAt: Date.now() + 60_000, + }), + }, + container: { + exec: async () => ({ + output: async () => ({ stdout: new TextEncoder().encode("phase:building-and-pushing") }), + }), + }, + }, + }) as RunnerImageBuilder; + + await expect(builder.buildStatus()).resolves.toEqual({ kind: "running", phase: "building-and-pushing" }); + }); + it("turns known batch exit statuses into non-sensitive setup failures", () => { expect(runnerImageBuilderExitError(10)).toContain("download the configured GitHub source archive"); expect(runnerImageBuilderExitError(13)).toContain("build and push the runner image"); @@ -211,6 +240,43 @@ describe("one-shot Cloudflare runner-image builder", () => { ); expect(runnerImageBuilderExitCode(13)).toBe(13); expect(runnerImageBuilderExitCode(Number.NaN)).toBe(1); + expect(runnerImageBuilderPolledExitCode("exited:13")).toBe(13); + expect(runnerImageBuilderPolledExitCode("13")).toBe(13); + expect(runnerImageBuilderPolledExitCode("phase:building-and-pushing")).toBeUndefined(); + }); + + it("publishes live build phases only from the Workflow that owns the build", () => { + const status = { kind: "running", phase: "building-and-pushing" } as const; + + expect(runnerImageBuildPhaseForOwner(true, status)).toBe("building-and-pushing"); + expect(runnerImageBuildPhaseForOwner(false, status)).toBeUndefined(); + }); + + it("does not let a joining Workflow publish source-download progress", async () => { + const updateBuildProgress = vi.fn<(workflowId: string, phase: string) => Promise>(async () => undefined); + // SAFETY: An active owner makes startBuild return before it uses any omitted Worker bindings or Container methods. + const builder = Object.assign(Object.create(RunnerImageBuilder.prototype), { + ctx: { + container: {}, + storage: { + get: async () => ({ + workflowId: "owner-workflow", + sourceArchiveKey: "runner-image-source/owner-workflow.tar.gz", + state: "active", + leaseExpiresAt: Date.now() + 60_000, + }), + }, + }, + env: { CLOUDFLARE_ACCOUNT_ID: "account", RUNNER_IMAGE_NAME: "runner" }, + updateBuildProgress, + }) as RunnerImageBuilder; + + await expect( + builder.startBuild("joining-workflow", { repository: { owner: "octo", repository: "runner" }, ref: "main" }), + ).resolves.toEqual({ + owner: false, + }); + expect(updateBuildProgress).not.toHaveBeenCalled(); }); it("stages an isolated context and reports each daemonless command-stage failure", () => { diff --git a/tests/setup.test.ts b/tests/setup.test.ts index 16d4c5e..6ca718f 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -5,7 +5,9 @@ import { cloudflareWorkflowInstanceUrl, collapseCloudflareAccountCandidates, deploymentProgressFromOutput, + existingWorkerTokenStatusMessages, extractWorkerBaseUrl, + formatSetupStepDuration, generateResourceTraceSigningKey, generateWebhookSecret, githubAppCanServeRunnerOwner, @@ -39,6 +41,7 @@ import { retryRemoteRunnerImageBuild, retryWorkerTokenValidation, runnerPoolSummary, + shouldCreateInitialGitHubApp, waitForRemoteRunnerImageBuild, waitForWorkerHealthCheck, waitForWorkerSetupAuthorization, @@ -71,6 +74,12 @@ describe("interactive setup helpers", () => { expect(generateResourceTraceSigningKey()).toMatch(/^[A-Za-z0-9_-]{43}$/u); }); + it("formats completed setup-step durations for terminal output", () => { + expect(formatSetupStepDuration(1)).toBe("1ms"); + expect(formatSetupStepDuration(1_250)).toBe("1.3s"); + expect(formatSetupStepDuration(61_900)).toBe("1m 1s"); + }); + it("validates setup-time R2 cache inputs", () => { expect(validRunnerCacheBucketName("cloudflare-github-actions-runner-cache")).toBe(true); expect(validRunnerCacheBucketName("Cloudflare Cache")).toBe(false); @@ -386,9 +395,17 @@ describe("interactive setup helpers", () => { expect(remoteRunnerImageBuildProgressMessage({ status: "queued" })).toBe( "Waiting for Cloudflare to schedule the image build", ); + expect(remoteRunnerImageBuildProgressMessage({ progress: { phase: "bootstrapping-builder" } })).toBe( + "Bootstrapping Cloudflare's private daemonless image builder", + ); expect(remoteRunnerImageBuildProgressMessage({ progress: { phase: "building-and-pushing" } })).toBe( "Building and pushing the runner image to Cloudflare's private registry", ); + expect( + remoteRunnerImageBuildProgressMessage({ + progress: { phase: "rolling-out", rollout: { processedApplications: 3, totalApplications: 6 } }, + }), + ).toBe("Rolling runner profiles to the new image (3/6 profiles checked)"); expect( remoteRunnerImageBuildFailure({ status: "errored", @@ -520,6 +537,33 @@ describe("interactive setup helpers", () => { expect(() => parseRunnerSetupTokenStatus("{}")).toThrow(/incomplete token validation information/u); }); + it("keeps a discovered GitHub App configuration until the user explicitly replaces it", () => { + const unavailableApp = { githubApp: false, githubAppWebhookSecret: false }; + + expect(shouldCreateInitialGitHubApp(unavailableApp, true)).toBe(false); + expect(shouldCreateInitialGitHubApp(unavailableApp, false)).toBe(true); + expect(shouldCreateInitialGitHubApp({ githubApp: true, githubAppWebhookSecret: true }, false)).toBe(false); + }); + + it("summarizes existing Worker credentials without exposing their values", () => { + expect( + existingWorkerTokenStatusMessages({ + cloudflareContainersToken: true, + cloudflareRegistryPush: true, + cloudflareResourceTagging: true, + githubApp: false, + githubAppWebhookSecret: true, + resourceTraceSigningKey: true, + runnerCacheSigningKey: false, + }), + ).toEqual([ + "✔ Cloudflare Containers Write + Tag Read/Write token: valid (reusing)", + "✘ GitHub App credentials: unavailable or rejected", + "✔ Runner resource-trace signing key: present (reusing)", + "✘ Runner R2-cache signing key: missing", + ]); + }); + it("retries temporary Worker validation authorization failures", async () => { let attempts = 0; const result = await retryWorkerTokenValidation(