diff --git a/CHANGELOG.md b/CHANGELOG.md index 119a66e5fe..08eaaaec13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ This is the log of notable changes to EAS CLI and related packages. - [eas-cli] Update workflow run logs in real time, instead of every 10 seconds. ([#4228](https://github.com/expo/eas-cli/pull/4228) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR)) - [build-tools] Add `expo-device-hub` web previews to Android web-preview-only, Agent Device, Argent, and Appium remote sessions. ([#4304](https://github.com/expo/eas-cli/pull/4304) by [@krystofwoldrich](https://github.com/krystofwoldrich)) +- [eas-cli] Add `eas workflow:ssh [command...]` to open an ssh session on the worker running a workflow job. ([#4032](https://github.com/expo/eas-cli/pull/4032) by [@gwdp](https://github.com/gwdp)) ### 🐛 Bug fixes diff --git a/packages/eas-cli/src/commands/workflow/__tests__/ssh.test.ts b/packages/eas-cli/src/commands/workflow/__tests__/ssh.test.ts new file mode 100644 index 0000000000..2659c299c8 --- /dev/null +++ b/packages/eas-cli/src/commands/workflow/__tests__/ssh.test.ts @@ -0,0 +1,387 @@ +import spawnAsync from '@expo/spawn-async'; +import { Config } from '@oclif/core'; +import fs from 'node:fs'; + +import { WorkflowJobSshQuery } from '../../../graphql/queries/WorkflowJobSshQuery'; +import Log from '../../../log'; +import { sleepAsync } from '../../../utils/promise'; +import WorkflowSsh, { + parseSshArgv, + resolveSshConnectStatus, + splitConnectionHost, + sshHostAliasForResource, + terminalSshStatusMessage, +} from '../ssh'; + +jest.mock('@expo/spawn-async', () => ({ __esModule: true, default: jest.fn() })); +jest.mock('../../../graphql/queries/WorkflowJobSshQuery'); +jest.mock('../../../log', () => ({ + __esModule: true, + default: { log: jest.fn(), error: jest.fn(), newLine: jest.fn() }, +})); +jest.mock('../../../ora', () => ({ + ora: jest.fn(() => { + const spinner = { start: jest.fn(), fail: jest.fn(), succeed: jest.fn(), stop: jest.fn() }; + spinner.start.mockReturnValue(spinner); + return spinner; + }), +})); +jest.mock('../../../utils/promise', () => ({ sleepAsync: jest.fn() })); +jest.mock('node:fs', () => { + const actual = jest.requireActual('node:fs'); + return { + ...actual, + promises: { ...actual.promises, mkdtemp: jest.fn(), writeFile: jest.fn(), rm: jest.fn() }, + }; +}); + +describe(parseSshArgv, () => { + it('takes the first token as the resource id and the rest as the passthrough command', () => { + expect(parseSshArgv(['job-id', 'ls', '-la'])).toEqual({ + showConnect: false, + resourceId: 'job-id', + command: ['ls', '-la'], + }); + }); + + it('detects --show-connect before the id and strips it', () => { + expect(parseSshArgv(['--show-connect', 'job-id'])).toEqual({ + showConnect: true, + resourceId: 'job-id', + command: [], + }); + }); + + it('detects --show-connect=true before the id', () => { + expect(parseSshArgv(['--show-connect=true', 'job-id'])).toEqual({ + showConnect: true, + resourceId: 'job-id', + command: [], + }); + }); + + it('leaves --show-connect after the id in the passthrough command', () => { + expect(parseSshArgv(['job-id', '--show-connect'])).toEqual({ + showConnect: false, + resourceId: 'job-id', + command: ['--show-connect'], + }); + }); + + it('throws on an unknown flag before the id', () => { + expect(() => parseSshArgv(['--show-conect', 'job-id'])).toThrow('Unknown flag'); + }); + + it('reports an undefined resource id when none is given', () => { + expect(parseSshArgv([])).toEqual({ showConnect: false, resourceId: undefined, command: [] }); + expect(parseSshArgv(['--show-connect'])).toEqual({ + showConnect: true, + resourceId: undefined, + command: [], + }); + }); +}); + +describe(resolveSshConnectStatus, () => { + it('is unknown when the resource did not resolve', () => { + expect(resolveSshConnectStatus(null)).toBe('unknown'); + }); + + it('is not-enabled when the job did not request ssh', () => { + expect( + resolveSshConnectStatus({ sshRequested: false, jobCompleted: false, session: null }) + ).toBe('not-enabled'); + }); + + it('is ended when ssh was requested, the job finished, and no session remains', () => { + expect(resolveSshConnectStatus({ sshRequested: true, jobCompleted: true, session: null })).toBe( + 'ended' + ); + }); + + it('is pending when ssh was requested and the job is still running without a session', () => { + expect( + resolveSshConnectStatus({ sshRequested: true, jobCompleted: false, session: null }) + ).toBe('pending'); + }); + + it('is pending while the worker is reconnecting', () => { + expect( + resolveSshConnectStatus({ + sshRequested: true, + jobCompleted: false, + session: { + connectionConfig: { host: 'uptermd.upterm.dev', secret: 'tok', reconnecting: true }, + }, + }) + ).toBe('pending'); + }); + + it('is ready when the session has a connection config', () => { + expect( + resolveSshConnectStatus({ + sshRequested: true, + jobCompleted: false, + session: { + connectionConfig: { host: 'uptermd.upterm.dev', secret: 'tok', reconnecting: false }, + }, + }) + ).toBe('ready'); + }); +}); + +describe(splitConnectionHost, () => { + it('returns the host with no port for a plain hostname', () => { + expect(splitConnectionHost('relay.expo.dev')).toEqual({ hostname: 'relay.expo.dev' }); + }); + + it('splits a host:port into host and numeric port', () => { + expect(splitConnectionHost('relay.expo.dev:8022')).toEqual({ + hostname: 'relay.expo.dev', + port: 8022, + }); + }); +}); + +describe(terminalSshStatusMessage, () => { + it('has no message while the session can still open', () => { + expect(terminalSshStatusMessage('pending', 'job-1')).toBeNull(); + expect(terminalSshStatusMessage('ready', 'job-1')).toBeNull(); + }); + + it('distinguishes not-enabled from ended', () => { + expect(terminalSshStatusMessage('not-enabled', 'job-1')).toMatch(/SSH was not enabled/); + expect(terminalSshStatusMessage('ended', 'job-1')).toMatch(/has ended/); + expect(terminalSshStatusMessage('unknown', 'job-1')).toMatch(/No workflow job found/); + }); +}); + +describe(sshHostAliasForResource, () => { + it('suffixes the alias with the start of the resource id', () => { + expect(sshHostAliasForResource('job-1')).toBe('eas-workflow-ssh-job1'); + }); + + it('drops characters that are not safe in an ssh config host alias', () => { + expect(sshHostAliasForResource('a b\nHost evil')).toBe('eas-workflow-ssh-abHostev'); + }); + + it('falls back to the bare alias when nothing usable remains', () => { + expect(sshHostAliasForResource('---')).toBe('eas-workflow-ssh'); + }); +}); + +describe(WorkflowSsh, () => { + const graphqlClient = {} as never; + const mockConnectInfo = jest.mocked(WorkflowJobSshQuery.connectInfoForResourceIdAsync); + const mockSpawn = jest.mocked(spawnAsync); + const mockSleep = jest.mocked(sleepAsync); + const mockMkdtemp = jest.mocked(fs.promises.mkdtemp); + const mockWriteFile = jest.mocked(fs.promises.writeFile); + const mockRm = jest.mocked(fs.promises.rm); + + let mockConfig: Config; + let previousExitCode: typeof process.exitCode; + + const readyInfo = { + sshRequested: true, + jobCompleted: false, + session: { + id: 'ts-1', + connectionConfig: { host: 'relay.expo.dev', secret: 'TOKENx', reconnecting: false }, + }, + }; + const pendingInfo = { sshRequested: true, jobCompleted: false, session: null }; + const endedInfo = { sshRequested: true, jobCompleted: true, session: null }; + const notEnabledInfo = { sshRequested: false, jobCompleted: false, session: null }; + + beforeAll(async () => { + mockConfig = new Config({ root: __dirname }); + mockConfig.runHook = async () => ({ failures: [], successes: [] }); + }); + + beforeEach(() => { + jest.clearAllMocks(); + previousExitCode = process.exitCode; + process.exitCode = undefined; + mockMkdtemp.mockResolvedValue('/tmp/eas-ssh-1' as never); + mockWriteFile.mockResolvedValue(undefined as never); + mockRm.mockResolvedValue(undefined as never); + mockSpawn.mockResolvedValue({ stdout: '', stderr: '' } as never); + mockSleep.mockResolvedValue(undefined); + }); + + afterEach(() => { + process.exitCode = previousExitCode; + }); + + function createCommand(argv: string[]): WorkflowSsh { + const command = new WorkflowSsh(argv, mockConfig); + // @ts-expect-error getContextAsync/parse are protected + jest.spyOn(command, 'getContextAsync').mockResolvedValue({ loggedIn: { graphqlClient } }); + // @ts-expect-error parse is protected on the oclif base + jest.spyOn(command, 'parse').mockResolvedValue({}); + return command; + } + + it('throws when no workflow job id is given', async () => { + await expect(createCommand([]).runAsync()).rejects.toThrow('Provide a workflow job id'); + }); + + it('reports unknown and exits 1 when the id does not resolve', async () => { + mockConnectInfo.mockResolvedValue(null); + await createCommand(['job-1']).runAsync(); + expect(Log.error).toHaveBeenCalledWith(expect.stringContaining('No workflow job found')); + expect(process.exitCode).toBe(1); + }); + + it('reports not-enabled and exits 1 when the job did not request ssh', async () => { + mockConnectInfo.mockResolvedValue(notEnabledInfo as never); + await createCommand(['job-1']).runAsync(); + expect(Log.error).toHaveBeenCalledWith(expect.stringContaining('was not enabled')); + expect(process.exitCode).toBe(1); + }); + + it('reports ended and exits 1 when the job finished without a live session', async () => { + mockConnectInfo.mockResolvedValue(endedInfo as never); + await createCommand(['job-1']).runAsync(); + expect(Log.error).toHaveBeenCalledWith(expect.stringContaining('has ended')); + expect(process.exitCode).toBe(1); + }); + + it('prints the connect command for --show-connect without spawning ssh', async () => { + mockConnectInfo.mockResolvedValue(readyInfo as never); + await createCommand(['--show-connect', 'job-1']).runAsync(); + expect(Log.log).toHaveBeenCalledWith( + 'ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null TOKENx@relay.expo.dev' + ); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it('prints --show-connect with -p and a hostname-only wss proxy for non-default SSH ports', async () => { + mockConnectInfo.mockResolvedValue({ + ...readyInfo, + session: { + id: 'ts-1', + connectionConfig: { + host: 'relay.expo.dev:8022', + secret: 'TOKENx', + reconnecting: false, + }, + }, + } as never); + await createCommand(['--show-connect', 'job-1']).runAsync(); + expect(Log.log).toHaveBeenCalledWith( + 'ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 8022 TOKENx@relay.expo.dev' + ); + expect(Log.log).toHaveBeenCalledWith( + ' ssh -o ProxyCommand="upterm proxy wss://TOKENx@relay.expo.dev" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 8022 TOKENx@relay.expo.dev' + ); + }); + + it('writes a 0600 config and opens ssh, then removes the config dir', async () => { + mockConnectInfo.mockResolvedValue(readyInfo as never); + await createCommand(['job-1', 'ls', '-la']).runAsync(); + expect(mockWriteFile).toHaveBeenCalledWith( + '/tmp/eas-ssh-1/config', + expect.stringContaining('HostName relay.expo.dev'), + { mode: 0o600 } + ); + expect(mockWriteFile).toHaveBeenCalledWith( + '/tmp/eas-ssh-1/config', + expect.stringContaining('Host eas-workflow-ssh-job1'), + { mode: 0o600 } + ); + expect(mockSpawn).toHaveBeenCalledWith( + 'ssh', + ['-F', '/tmp/eas-ssh-1/config', 'eas-workflow-ssh-job1', 'ls', '-la'], + { stdio: 'inherit' } + ); + expect(mockRm).toHaveBeenCalledWith('/tmp/eas-ssh-1', { recursive: true, force: true }); + }); + + it('waits for a pending session to open, then connects', async () => { + mockConnectInfo + .mockResolvedValueOnce(pendingInfo as never) // initial status check + .mockResolvedValueOnce(pendingInfo as never) // first wait poll + .mockResolvedValue(readyInfo as never); + await createCommand(['job-1']).runAsync(); + expect(mockSleep).toHaveBeenCalled(); + expect(mockSpawn).toHaveBeenCalled(); + }); + + it('exits 1 when a pending session ends before it opens', async () => { + mockConnectInfo + .mockResolvedValueOnce(pendingInfo as never) + .mockResolvedValue(endedInfo as never); + await createCommand(['job-1']).runAsync(); + expect(mockSpawn).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it('exits 1 when waiting for the session to open times out', async () => { + jest.useFakeTimers(); + jest.setSystemTime(0); + try { + mockConnectInfo.mockResolvedValue(pendingInfo as never); + mockSleep.mockImplementation(async () => { + jest.setSystemTime(Date.now() + 6 * 60 * 1000); + }); + await createCommand(['job-1']).runAsync(); + expect(mockSpawn).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + } finally { + jest.useRealTimers(); + } + }); + + it('throws on an unexpected connection host', async () => { + mockConnectInfo.mockResolvedValue({ + sshRequested: true, + jobCompleted: false, + session: { + connectionConfig: { host: 'bad host', secret: 'TOKENx', reconnecting: false }, + }, + } as never); + await expect(createCommand(['job-1']).runAsync()).rejects.toThrow('connection host'); + }); + + it('propagates the ssh exit code from the catch handler', async () => { + const command = createCommand(['job-1']); + // @ts-expect-error isRunningSubprocess is private + command.isRunningSubprocess = true; + const err = Object.assign(new Error('ssh exited'), { status: 42 }); + // @ts-expect-error catch is protected + await command.catch(err); + expect(process.exitCode).toBe(42); + }); + + it('reports a missing ssh client (ENOENT) from the catch handler', async () => { + const command = createCommand(['job-1']); + // @ts-expect-error isRunningSubprocess is private + command.isRunningSubprocess = true; + const err = Object.assign(new Error('spawn ssh ENOENT'), { code: 'ENOENT' }); + // @ts-expect-error catch is protected + await command.catch(err); + expect(process.exitCode).toBe(1); + expect(Log.error).toHaveBeenCalledWith(expect.stringContaining('Install an OpenSSH client')); + }); + + it('delegates non-subprocess errors to the base handler', async () => { + const command = createCommand(['job-1']); + let threw = false; + try { + // @ts-expect-error catch is protected + await command.catch(new Error('boom')); + } catch { + threw = true; + } + expect(threw).toBe(true); + }); + + it('stops the spinner and rethrows when polling fails while waiting', async () => { + mockConnectInfo + .mockResolvedValueOnce(pendingInfo as never) + .mockRejectedValue(new Error('network blip')); + await expect(createCommand(['job-1']).runAsync()).rejects.toThrow('network blip'); + }); +}); diff --git a/packages/eas-cli/src/commands/workflow/ssh.ts b/packages/eas-cli/src/commands/workflow/ssh.ts new file mode 100644 index 0000000000..5c50bbb942 --- /dev/null +++ b/packages/eas-cli/src/commands/workflow/ssh.ts @@ -0,0 +1,252 @@ +import spawnAsync from '@expo/spawn-async'; +import { Flags } from '@oclif/core'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import EasCommand from '../../commandUtils/EasCommand'; +import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; +import { + WorkflowJobSshConnectInfo, + WorkflowJobSshQuery, + WorkflowJobSshSession, +} from '../../graphql/queries/WorkflowJobSshQuery'; +import Log from '../../log'; +import { ora } from '../../ora'; +import { sleepAsync } from '../../utils/promise'; + +export function splitConnectionHost(connectionHost: string): { hostname: string; port?: number } { + try { + const { hostname, port: stringPort } = new URL(`ssh://${connectionHost}`); + const port = stringPort === '' ? undefined : Number(stringPort); + return { hostname, port }; + } catch (err) { + throw new Error( + 'Unexpected connection host reported for this ssh session. Update eas-cli and try again, or contact support if it persists.', + { cause: err } + ); + } +} + +const SSH_INSECURE_OPTS = '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'; + +const SESSION_OPEN_TIMEOUT_MS = 5 * 60 * 1000; +const SESSION_OPEN_POLL_INTERVAL_MS = 3000; + +export type SshConnectStatus = 'unknown' | 'not-enabled' | 'ended' | 'pending' | 'ready'; + +export function resolveSshConnectStatus( + connectInfo: + | (Pick & { + session?: Pick | null; + }) + | null +): SshConnectStatus { + if (!connectInfo) { + return 'unknown'; + } + const { sshRequested, jobCompleted, session } = connectInfo; + if (session?.connectionConfig && !session.connectionConfig.reconnecting) { + return 'ready'; + } + if (session?.connectionConfig?.reconnecting) { + return 'pending'; + } + if (!sshRequested) { + return 'not-enabled'; + } + return jobCompleted ? 'ended' : 'pending'; +} + +export function terminalSshStatusMessage( + status: SshConnectStatus, + resourceId: string +): string | null { + switch (status) { + case 'unknown': + return `No workflow job found for "${resourceId}". Pass a workflow job id from a run started with \`eas workflow:run --ssh\`.`; + case 'not-enabled': + return `SSH was not enabled for "${resourceId}". Start the run with \`eas workflow:run --ssh\` to enable it.`; + case 'ended': + return 'This ssh session has ended.'; + case 'pending': + case 'ready': + return null; + } +} + +export function sshHostAliasForResource(resourceId: string): string { + const suffix = resourceId.replace(/[^A-Za-z0-9]/g, '').slice(0, 8); + return suffix ? `eas-workflow-ssh-${suffix}` : 'eas-workflow-ssh'; +} + +export function parseSshArgv(rawArgv: readonly string[]): { + showConnect: boolean; + resourceId: string | undefined; + command: string[]; +} { + let showConnect = false; + let index = 0; + while (index < rawArgv.length && rawArgv[index].startsWith('-')) { + if (rawArgv[index] === '--show-connect' || rawArgv[index] === '--show-connect=true') { + showConnect = true; + } else { + throw new Error( + `Unknown flag "${rawArgv[index]}" before the id. The only supported flag is --show-connect; everything after the id is passed to the remote shell.` + ); + } + index += 1; + } + const [resourceId, ...command] = rawArgv.slice(index); + return { showConnect, resourceId, command }; +} + +export default class WorkflowSsh extends EasCommand { + static override hidden = true; + + static override description = + '[EXPERIMENTAL] open an ssh session on the worker running a workflow job'; + + static override strict = false; + + static override flags = { + 'show-connect': Flags.boolean({ + description: 'Print the ssh connection command (host and token) instead of opening a session', + default: false, + }), + }; + + static override contextDefinition = { + ...this.ContextOptions.LoggedIn, + }; + + private isRunningSubprocess = false; + + async runAsync(): Promise { + const rawArgv = [...this.argv]; + await this.parse(WorkflowSsh, []); + const { showConnect, resourceId, command } = parseSshArgv(rawArgv); + + if (typeof resourceId !== 'string' || resourceId.length === 0) { + throw new Error('Provide a workflow job id: eas workflow:ssh [command...]'); + } + + const { + loggedIn: { graphqlClient }, + } = await this.getContextAsync(WorkflowSsh, { nonInteractive: true }); + + const connectInfo = await WorkflowJobSshQuery.connectInfoForResourceIdAsync( + graphqlClient, + resourceId + ); + const status = resolveSshConnectStatus(connectInfo); + const statusMessage = terminalSshStatusMessage(status, resourceId); + if (statusMessage) { + Log.error(statusMessage); + process.exitCode = 1; + return; + } + + const connectionConfig = + (status === 'ready' ? connectInfo?.session?.connectionConfig : null) ?? + (await waitForSessionToOpenAsync(graphqlClient, resourceId)); + if (!connectionConfig) { + process.exitCode = 1; + return; + } + + const { host: connectionHost, secret } = connectionConfig; + const { hostname, port } = splitConnectionHost(connectionHost); + const portOption = port !== undefined ? ` -p ${port}` : ''; + + if (showConnect) { + Log.log(`ssh ${SSH_INSECURE_OPTS}${portOption} ${secret}@${hostname}`); + Log.newLine(); + Log.log( + 'If your network blocks the direct SSH connection, reach the session through the WebSocket relay with upterm (https://upterm.dev):' + ); + // WSS terminates on the relay hostname (default 443). Do not paste an SSH + // :port into the wss:// URL — that port is only for the ssh destination. + Log.log( + ` ssh -o ProxyCommand="upterm proxy wss://${secret}@${hostname}" ${SSH_INSECURE_OPTS}${portOption} ${secret}@${hostname}` + ); + return; + } + + const configDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-ssh-')); + try { + const configPath = path.join(configDir, 'config'); + const hostAlias = sshHostAliasForResource(resourceId); + await fs.promises.writeFile( + configPath, + [ + `Host ${hostAlias}`, + ` HostName ${hostname}`, + ...(port !== undefined ? [` Port ${port}`] : []), + ` User ${secret}`, + ' StrictHostKeyChecking no', + ' UserKnownHostsFile /dev/null', + '', + ].join('\n'), + { mode: 0o600 } + ); + + this.isRunningSubprocess = true; + await spawnAsync('ssh', ['-F', configPath, hostAlias, ...command], { + stdio: 'inherit', + }); + } finally { + await fs.promises.rm(configDir, { recursive: true, force: true }); + } + } + + protected override catch(err: Error): Promise { + if (this.isRunningSubprocess) { + if ((err as Error & { code?: string }).code === 'ENOENT') { + Log.error( + 'Could not run `ssh`. Install an OpenSSH client and make sure `ssh` is on your PATH, then try again.' + ); + process.exitCode = 1; + return Promise.resolve(); + } + const status = (err as Error & { status?: number | null }).status; + process.exitCode = process.exitCode ?? status ?? 1; + return Promise.resolve(); + } + return super.catch(err); + } +} + +async function waitForSessionToOpenAsync( + graphqlClient: ExpoGraphqlClient, + workflowJobId: string +): Promise | null> { + const spinner = ora('Waiting for the worker to open the ssh session').start(); + const deadline = Date.now() + SESSION_OPEN_TIMEOUT_MS; + try { + while (Date.now() < deadline) { + const connectInfo = await WorkflowJobSshQuery.connectInfoForResourceIdAsync( + graphqlClient, + workflowJobId + ); + const status = resolveSshConnectStatus(connectInfo); + const statusMessage = terminalSshStatusMessage(status, workflowJobId); + if (statusMessage) { + spinner.fail(statusMessage); + return null; + } + if (status === 'ready') { + spinner.succeed('The ssh session is ready.'); + return connectInfo?.session?.connectionConfig ?? null; + } + await sleepAsync(SESSION_OPEN_POLL_INTERVAL_MS); + } + spinner.fail( + 'Timed out waiting for the ssh session to open. The worker may still be starting up; try again in a moment.' + ); + return null; + } catch (err) { + spinner.stop(); + throw err; + } +} diff --git a/packages/eas-cli/src/graphql/generated.ts b/packages/eas-cli/src/graphql/generated.ts index dccc8b2a0a..268979eca4 100644 --- a/packages/eas-cli/src/graphql/generated.ts +++ b/packages/eas-cli/src/graphql/generated.ts @@ -15671,6 +15671,20 @@ export type WorkflowJobByIdQuery = { __typename?: 'RootQuery', workflowJobs: { _ | { __typename: 'User', id: string, displayName: string } | null, app: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, updateChannel?: { __typename?: 'UpdateChannel', id: string, name: string } | null, runtime?: { __typename?: 'Runtime', id: string, version: string } | null, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null } | null, errors: Array<{ __typename?: 'WorkflowJobError', title: string, message: string }> } } }; +export type WorkflowJobSshPollQueryVariables = Exact<{ + workflowJobId: Scalars['ID']['input']; +}>; + + +export type WorkflowJobSshPollQuery = { __typename?: 'RootQuery', workflowJobs: { __typename?: 'WorkflowJobQuery', byId: { __typename?: 'WorkflowJob', id: string, status: WorkflowJobStatus, workflowRun: { __typename?: 'WorkflowRun', id: string, sshSettings?: { __typename?: 'WorkflowRunSshSettings', idleTimeoutSeconds: number } | null }, turtleJobRun?: { __typename?: 'JobRun', id: string, sshSession?: { __typename?: 'TurtleSshSession', id: string, connectionConfig: { __typename?: 'TurtleSshConnectionConfig', host: string, secret: string, reconnecting: boolean } } | null } | null, turtleBuild?: { __typename?: 'Build', id: string, sshSession?: { __typename?: 'TurtleSshSession', id: string, connectionConfig: { __typename?: 'TurtleSshConnectionConfig', host: string, secret: string, reconnecting: boolean } } | null } | null } } }; + +export type JobRunSshPollQueryVariables = Exact<{ + jobRunId: Scalars['ID']['input']; +}>; + + +export type JobRunSshPollQuery = { __typename?: 'RootQuery', jobRun: { __typename?: 'JobRunQuery', byId: { __typename?: 'JobRun', id: string, status: JobRunStatus, sshSession?: { __typename?: 'TurtleSshSession', id: string, connectionConfig: { __typename?: 'TurtleSshConnectionConfig', host: string, secret: string, reconnecting: boolean } } | null } } }; + export type ExpoGoSupportedSdkVersionsQueryVariables = Exact<{ [key: string]: never; }>; diff --git a/packages/eas-cli/src/graphql/queries/WorkflowJobSshQuery.ts b/packages/eas-cli/src/graphql/queries/WorkflowJobSshQuery.ts new file mode 100644 index 0000000000..dd3e69b396 --- /dev/null +++ b/packages/eas-cli/src/graphql/queries/WorkflowJobSshQuery.ts @@ -0,0 +1,213 @@ +import gql from 'graphql-tag'; + +import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; +import { GraphqlError, withErrorHandlingAsync } from '../client'; +import { JobRunStatus, WorkflowJobStatus } from '../generated'; + +const FINAL_WORKFLOW_JOB_STATUSES = new Set([ + WorkflowJobStatus.Success, + WorkflowJobStatus.Failure, + WorkflowJobStatus.Canceled, + WorkflowJobStatus.Skipped, +]); + +const FINAL_JOB_RUN_STATUSES = new Set([ + JobRunStatus.Errored, + JobRunStatus.Finished, + JobRunStatus.Canceled, +]); + +export type WorkflowJobSshConnectionConfig = { + host: string; + secret: string; + reconnecting: boolean; +}; + +export type WorkflowJobSshSession = { + id: string; + connectionConfig: WorkflowJobSshConnectionConfig; +}; + +export type WorkflowJobSshConnectInfo = { + sshRequested: boolean; + jobCompleted: boolean; + session: WorkflowJobSshSession | null; +}; + +type WorkflowJobSshPollQuery = { + workflowJobs: { + byId: { + id: string; + status: WorkflowJobStatus; + workflowRun: { + id: string; + sshSettings: { idleTimeoutSeconds: number } | null; + }; + turtleJobRun: { id: string; sshSession: WorkflowJobSshSession | null } | null; + turtleBuild: { id: string; sshSession: WorkflowJobSshSession | null } | null; + }; + }; +}; + +type WorkflowJobSshPollQueryVariables = { + workflowJobId: string; +}; + +type JobRunSshPollQuery = { + jobRun: { + byId: { + id: string; + status: JobRunStatus; + sshSession: WorkflowJobSshSession | null; + }; + }; +}; + +type JobRunSshPollQueryVariables = { + jobRunId: string; +}; + +function toConnectInfo( + job: WorkflowJobSshPollQuery['workflowJobs']['byId'] +): WorkflowJobSshConnectInfo { + const hasTurtleTarget = job.turtleJobRun != null || job.turtleBuild != null; + return { + sshRequested: hasTurtleTarget && job.workflowRun.sshSettings != null, + jobCompleted: FINAL_WORKFLOW_JOB_STATUSES.has(job.status), + session: job.turtleJobRun?.sshSession ?? job.turtleBuild?.sshSession ?? null, + }; +} + +function isNotFoundError(error: unknown): boolean { + if (!(error instanceof GraphqlError)) { + return false; + } + return error.graphQLErrors.some(e => { + const code = e?.extensions?.errorCode ?? e?.extensions?.code; + return ( + code === 'ENTITY_NOT_FOUND' || + /Entity not found/i.test(e?.message ?? '') || + /not found/i.test(e?.message ?? '') + ); + }); +} + +export const WorkflowJobSshQuery = { + async connectInfoForWorkflowJobAsync( + graphqlClient: ExpoGraphqlClient, + workflowJobId: string + ): Promise { + let data: WorkflowJobSshPollQuery; + try { + data = await withErrorHandlingAsync( + graphqlClient + .query( + gql` + query WorkflowJobSshPoll($workflowJobId: ID!) { + workflowJobs { + byId(workflowJobId: $workflowJobId) { + id + status + workflowRun { + id + sshSettings { + idleTimeoutSeconds + } + } + turtleJobRun { + id + sshSession { + id + connectionConfig { + host + secret + reconnecting + } + } + } + turtleBuild { + id + sshSession { + id + connectionConfig { + host + secret + reconnecting + } + } + } + } + } + } + `, + { workflowJobId }, + { requestPolicy: 'network-only' } + ) + .toPromise() + ); + } catch (error) { + if (isNotFoundError(error)) { + return null; + } + throw error; + } + return toConnectInfo(data.workflowJobs.byId); + }, + + async connectInfoForJobRunAsync( + graphqlClient: ExpoGraphqlClient, + jobRunId: string + ): Promise { + let data: JobRunSshPollQuery; + try { + data = await withErrorHandlingAsync( + graphqlClient + .query( + gql` + query JobRunSshPoll($jobRunId: ID!) { + jobRun { + byId(jobRunId: $jobRunId) { + id + status + sshSession { + id + connectionConfig { + host + secret + reconnecting + } + } + } + } + } + `, + { jobRunId }, + { requestPolicy: 'network-only' } + ) + .toPromise() + ); + } catch (error) { + if (isNotFoundError(error)) { + return null; + } + throw error; + } + const jobRun = data.jobRun.byId; + const jobCompleted = FINAL_JOB_RUN_STATUSES.has(jobRun.status); + return { + sshRequested: jobRun.sshSession != null || jobCompleted, + jobCompleted, + session: jobRun.sshSession, + }; + }, + + async connectInfoForResourceIdAsync( + graphqlClient: ExpoGraphqlClient, + resourceId: string + ): Promise { + return ( + (await WorkflowJobSshQuery.connectInfoForWorkflowJobAsync(graphqlClient, resourceId)) ?? + (await WorkflowJobSshQuery.connectInfoForJobRunAsync(graphqlClient, resourceId)) + ); + }, +}; diff --git a/packages/eas-cli/src/graphql/queries/__tests__/WorkflowJobSshQuery-test.ts b/packages/eas-cli/src/graphql/queries/__tests__/WorkflowJobSshQuery-test.ts new file mode 100644 index 0000000000..e35a07ee9f --- /dev/null +++ b/packages/eas-cli/src/graphql/queries/__tests__/WorkflowJobSshQuery-test.ts @@ -0,0 +1,155 @@ +import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; +import { GraphqlError } from '../../client'; +import { WorkflowJobStatus } from '../../generated'; +import { WorkflowJobSshQuery } from '../WorkflowJobSshQuery'; + +describe(WorkflowJobSshQuery.connectInfoForWorkflowJobAsync.name, () => { + function makeClient(byId: unknown): { + graphqlClient: ExpoGraphqlClient; + query: jest.Mock; + } { + const query = jest.fn().mockReturnValue({ + toPromise: async () => ({ data: { workflowJobs: { byId } } }), + }); + return { graphqlClient: { query } as unknown as ExpoGraphqlClient, query }; + } + + it('maps workflowJobs.byId into connect info', async () => { + const { graphqlClient, query } = makeClient({ + id: 'job-1', + status: WorkflowJobStatus.InProgress, + workflowRun: { sshSettings: { idleTimeoutSeconds: 0 } }, + turtleJobRun: { + sshSession: { + id: 'ts-1', + connectionConfig: { host: 'relay.expo.dev', secret: 'TOKENx', reconnecting: false }, + }, + }, + turtleBuild: null, + }); + + expect( + await WorkflowJobSshQuery.connectInfoForWorkflowJobAsync(graphqlClient, 'job-1') + ).toEqual({ + sshRequested: true, + jobCompleted: false, + session: { + id: 'ts-1', + connectionConfig: { host: 'relay.expo.dev', secret: 'TOKENx', reconnecting: false }, + }, + }); + expect(query).toHaveBeenCalledWith( + expect.anything(), + { workflowJobId: 'job-1' }, + { requestPolicy: 'network-only' } + ); + }); + + it('is not ssh-requested when the run has no sshSettings', async () => { + const { graphqlClient } = makeClient({ + id: 'job-1', + status: WorkflowJobStatus.InProgress, + workflowRun: { sshSettings: null }, + turtleJobRun: null, + turtleBuild: { sshSession: null }, + }); + + expect( + await WorkflowJobSshQuery.connectInfoForWorkflowJobAsync(graphqlClient, 'job-1') + ).toMatchObject({ sshRequested: false }); + }); + + it('returns null when the job is not found', async () => { + const query = jest.fn().mockReturnValue({ + toPromise: async () => ({ + data: undefined, + error: new GraphqlError({ + graphQLErrors: [ + { message: 'Entity not found', extensions: { errorCode: 'ENTITY_NOT_FOUND' } }, + ], + networkError: undefined, + response: undefined, + }), + }), + }); + const graphqlClient = { query } as unknown as ExpoGraphqlClient; + expect( + await WorkflowJobSshQuery.connectInfoForWorkflowJobAsync(graphqlClient, 'missing') + ).toBeNull(); + }); + + it('returns null for not-found errors matched by message or code', async () => { + for (const graphQLErrors of [ + [{ message: 'Workflow job not found', extensions: {} }], + [{ message: 'missing', extensions: { code: 'ENTITY_NOT_FOUND' } }], + ]) { + const query = jest.fn().mockReturnValue({ + toPromise: async () => ({ + data: undefined, + error: new GraphqlError({ + graphQLErrors, + networkError: undefined, + response: undefined, + }), + }), + }); + const graphqlClient = { query } as unknown as ExpoGraphqlClient; + expect( + await WorkflowJobSshQuery.connectInfoForWorkflowJobAsync(graphqlClient, 'missing') + ).toBeNull(); + } + }); + + it('uses the turtleBuild session when there is no turtleJobRun', async () => { + const { graphqlClient } = makeClient({ + id: 'job-1', + status: WorkflowJobStatus.Success, + workflowRun: { sshSettings: { idleTimeoutSeconds: 60 } }, + turtleJobRun: null, + turtleBuild: { + sshSession: { + id: 'ts-build', + connectionConfig: { host: 'relay.expo.dev', secret: 'TOK', reconnecting: true }, + }, + }, + }); + + expect( + await WorkflowJobSshQuery.connectInfoForWorkflowJobAsync(graphqlClient, 'job-1') + ).toEqual({ + sshRequested: true, + jobCompleted: true, + session: { + id: 'ts-build', + connectionConfig: { host: 'relay.expo.dev', secret: 'TOK', reconnecting: true }, + }, + }); + }); + + it('rethrows GraphQL errors that are not not-found', async () => { + const error = new GraphqlError({ + graphQLErrors: [{ message: 'boom', extensions: { errorCode: 'INTERNAL' } }], + networkError: undefined, + response: undefined, + }); + const query = jest.fn().mockReturnValue({ + toPromise: async () => ({ data: undefined, error }), + }); + const graphqlClient = { query } as unknown as ExpoGraphqlClient; + await expect( + WorkflowJobSshQuery.connectInfoForWorkflowJobAsync(graphqlClient, 'job-1') + ).rejects.toBe(error); + }); + + it('rethrows unexpected non-GraphQL errors', async () => { + const query = jest.fn().mockReturnValue({ + toPromise: async () => { + throw new Error('network down'); + }, + }); + const graphqlClient = { query } as unknown as ExpoGraphqlClient; + await expect( + WorkflowJobSshQuery.connectInfoForWorkflowJobAsync(graphqlClient, 'job-1') + ).rejects.toThrow('network down'); + }); +});