diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index 510e483e3..04d635812 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -21,7 +21,7 @@ import { CommandExitCodes, DEPRECATED_LOCAL_CONFIG_NAME, LOCAL_CONFIG_PATH } fro import { sumFilesSizeInBytes } from '../../lib/files.js'; import { useAbortJobOnSignal } from '../../lib/hooks/useAbortJobOnSignal.js'; import { useActorConfig } from '../../lib/hooks/useActorConfig.js'; -import { error, info, link, run, success, warning } from '../../lib/outputs.js'; +import { error, info, run, simpleLog, warning } from '../../lib/outputs.js'; import { transformEnvToEnvVars } from '../../lib/secrets.js'; import { createActZip, @@ -48,6 +48,54 @@ const DEFAULT_ACTOR_VERSION_NUMBER = '0.0'; // that changes, we have to add it. const DEFAULT_BUILD_TAG = 'latest'; +// How many trailing log lines to surface as the failure reason. +const BUILD_LOG_TAIL_LINES = 10; + +interface PushResult { + ok: boolean; + operation: 'push'; + actor: { id: string; url: string }; + build: { id: string; number: string; status: string; url: string }; + error?: { phase: 'build'; message: string; logTail: string[] }; + exitCode: number; +} + +// Maps the final build status to the overall push outcome. `ok` mirrors command +// success (true iff exitCode is 0): a still-running fire-and-forget build is not +// a failure — its pending state is conveyed by the build status, not by `ok`. +export function resolvePushOutcome(buildStatus: string): { + resultLabel: string; + exitCode: number; + ok: boolean; + errorMessage?: string; +} { + switch (buildStatus) { + case ACTOR_JOB_STATUSES.SUCCEEDED: + return { resultLabel: 'SUCCEEDED', exitCode: 0, ok: true }; + case ACTOR_JOB_STATUSES.READY: + case ACTOR_JOB_STATUSES.RUNNING: + return { resultLabel: 'RUNNING', exitCode: 0, ok: true }; + case ACTOR_JOB_STATUSES.ABORTED: + case ACTOR_JOB_STATUSES.ABORTING: + return { + resultLabel: 'ABORTED', + exitCode: CommandExitCodes.BuildAborted, + ok: false, + errorMessage: 'Build aborted', + }; + case ACTOR_JOB_STATUSES.TIMED_OUT: + case ACTOR_JOB_STATUSES.TIMING_OUT: + return { + resultLabel: 'TIMED_OUT', + exitCode: CommandExitCodes.BuildTimedOut, + ok: false, + errorMessage: 'Build timed out', + }; + default: + return { resultLabel: 'FAILED', exitCode: CommandExitCodes.BuildFailed, ok: false, errorMessage: 'Build failed' }; + } +} + export class ActorsPushCommand extends ApifyCommand { static override name = 'push' as const; @@ -424,44 +472,68 @@ Skipping push. Use --force to override.`, } } - if (this.flags.json) { - printJsonToStdout(build); - return; + const buildStatus = build.status as string; + const outcome = resolvePushOutcome(buildStatus); + + const actorUrl = `https://console.apify.com${redirectUrlPart}/actors/${build.actId}`; + const buildUrl = `${actorUrl}#/builds/${build.buildNumber}`; + + // Surface the tail of the build log as the failure reason. Best-effort: + // the build status already conveys the outcome if the log can't be read. + let logTail: string[] = []; + if (outcome.errorMessage) { + try { + const log = await apifyClient.log(build.id).get(); + if (log) { + logTail = log + .split('\n') + .map((line) => line.trimEnd()) + .filter((line) => line.length > 0) + .slice(-BUILD_LOG_TAIL_LINES); + } + } catch { + // ignore — reason block is optional + } } - link({ - message: 'Actor build detail', - url: `https://console.apify.com${redirectUrlPart}/actors/${build.actId}#/builds/${build.buildNumber}`, - }); + if (outcome.exitCode !== 0) { + process.exitCode = outcome.exitCode; + } - link({ - message: 'Actor detail', - url: `https://console.apify.com${redirectUrlPart}/actors/${build.actId}`, - }); + const result: PushResult = { + ok: outcome.ok, + operation: 'push', + actor: { id: build.actId, url: actorUrl }, + build: { id: build.id, number: build.buildNumber, status: buildStatus, url: buildUrl }, + exitCode: outcome.exitCode, + }; + if (outcome.errorMessage) { + result.error = { phase: 'build', message: outcome.errorMessage, logTail }; + } - if (this.flags.open) { - await open(`https://console.apify.com${redirectUrlPart}/actors/${build.actId}`); + if (this.flags.json) { + printJsonToStdout(result); + return; } - if (build.status === ACTOR_JOB_STATUSES.SUCCEEDED) { - success({ message: 'Actor was deployed to Apify cloud and built there.' }); - // @ts-expect-error FIX THESE TYPES 😢 - } else if (build.status === ACTOR_JOB_STATUSES.READY) { - warning({ message: 'Build is waiting for allocation.' }); - // @ts-expect-error FIX THESE TYPES 😢 - } else if (build.status === ACTOR_JOB_STATUSES.RUNNING) { - warning({ message: 'Build is still running.' }); - // @ts-expect-error FIX THESE TYPES 😢 - } else if (build.status === ACTOR_JOB_STATUSES.ABORTED || build.status === ACTOR_JOB_STATUSES.ABORTING) { - warning({ message: 'Build was aborted!' }); - process.exitCode = CommandExitCodes.BuildAborted; - // @ts-expect-error FIX THESE TYPES 😢 - } else if (build.status === ACTOR_JOB_STATUSES.TIMED_OUT || build.status === ACTOR_JOB_STATUSES.TIMING_OUT) { - warning({ message: 'Build timed out!' }); - process.exitCode = CommandExitCodes.BuildTimedOut; - } else { - error({ message: 'Build failed!' }); - process.exitCode = CommandExitCodes.BuildFailed; + const lines = [ + `Apify push result: ${outcome.resultLabel}`, + '', + 'Upload: SUCCEEDED', + `Build: ${buildStatus}`, + `Actor ID: ${build.actId}`, + `Build ID: ${build.id}`, + `Build number: ${build.buildNumber}`, + ...(outcome.exitCode !== 0 ? [`Exit code: ${outcome.exitCode}`] : []), + '', + `Actor URL: ${actorUrl}`, + `Build URL: ${buildUrl}`, + ...(outcome.errorMessage && logTail.length ? ['', 'Reason:', ...logTail] : []), + ]; + simpleLog({ stdout: true, message: lines.join('\n') }); + + if (this.flags.open) { + await open(actorUrl); } } } diff --git a/test/local/commands/push.test.ts b/test/local/commands/push.test.ts new file mode 100644 index 000000000..aad78a5d0 --- /dev/null +++ b/test/local/commands/push.test.ts @@ -0,0 +1,28 @@ +import { ACTOR_JOB_STATUSES } from '@apify/consts'; + +import { resolvePushOutcome } from '../../../src/commands/actors/push.js'; +import { CommandExitCodes } from '../../../src/lib/consts.js'; + +describe('resolvePushOutcome', () => { + test.each([ + [ACTOR_JOB_STATUSES.SUCCEEDED, { resultLabel: 'SUCCEEDED', exitCode: 0, ok: true }], + [ACTOR_JOB_STATUSES.READY, { resultLabel: 'RUNNING', exitCode: 0, ok: true }], + [ACTOR_JOB_STATUSES.RUNNING, { resultLabel: 'RUNNING', exitCode: 0, ok: true }], + [ACTOR_JOB_STATUSES.ABORTED, { resultLabel: 'ABORTED', exitCode: CommandExitCodes.BuildAborted, ok: false }], + [ACTOR_JOB_STATUSES.ABORTING, { resultLabel: 'ABORTED', exitCode: CommandExitCodes.BuildAborted, ok: false }], + [ACTOR_JOB_STATUSES.TIMED_OUT, { resultLabel: 'TIMED_OUT', exitCode: CommandExitCodes.BuildTimedOut, ok: false }], + [ACTOR_JOB_STATUSES.TIMING_OUT, { resultLabel: 'TIMED_OUT', exitCode: CommandExitCodes.BuildTimedOut, ok: false }], + [ACTOR_JOB_STATUSES.FAILED, { resultLabel: 'FAILED', exitCode: CommandExitCodes.BuildFailed, ok: false }], + ['SOME_UNKNOWN_STATUS', { resultLabel: 'FAILED', exitCode: CommandExitCodes.BuildFailed, ok: false }], + ])('maps build status %s to the expected outcome', (status, expected) => { + expect(resolvePushOutcome(status)).toMatchObject(expected); + }); + + test('failing outcomes carry an error message, successful ones do not', () => { + expect(resolvePushOutcome(ACTOR_JOB_STATUSES.SUCCEEDED).errorMessage).toBeUndefined(); + expect(resolvePushOutcome(ACTOR_JOB_STATUSES.RUNNING).errorMessage).toBeUndefined(); + expect(resolvePushOutcome(ACTOR_JOB_STATUSES.ABORTED).errorMessage).toBe('Build aborted'); + expect(resolvePushOutcome(ACTOR_JOB_STATUSES.TIMED_OUT).errorMessage).toBe('Build timed out'); + expect(resolvePushOutcome(ACTOR_JOB_STATUSES.FAILED).errorMessage).toBe('Build failed'); + }); +});