diff --git a/__tests__/commands/wp-ssh.ts b/__tests__/commands/wp-ssh.ts new file mode 100644 index 000000000..c38e02fb1 --- /dev/null +++ b/__tests__/commands/wp-ssh.ts @@ -0,0 +1,160 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import Stream from 'node:stream'; +import { Client } from 'ssh2'; +import { PassThrough } from 'stream'; + +import { WPCliCommandOverSSH } from '../../src/commands/wp-ssh'; +import API from '../../src/lib/api'; +import { CommandTracker } from '../../src/lib/tracker'; + +const processExitMock = jest + .spyOn( process, 'exit' ) + .mockImplementation( ( code?: string | number | null | undefined ) => { + throw new Error( `Process exited with code: ${ code }` ); + } ); + +const consoleErrorMock = jest.spyOn( console, 'error' ).mockImplementation( () => {} ); + +const mockExec = jest.fn< Client[ 'exec' ] >(); +const mockEnd = jest.fn< Client[ 'end' ] >(); + +const EventEmitter = jest.requireActual< typeof import('events') >( 'events' ); + +class MockClient extends EventEmitter { + public connect() { + this.emit( 'ready' ); + + return this; + } + + public exec = mockExec; + + public end = mockEnd; +} + +jest.mock( 'ssh2', () => { + const original = jest.requireActual< typeof import('ssh2') >( 'ssh2' ); + + return { + ...original, + __esModule: true, + + Client: jest.fn().mockImplementation( () => { + return new MockClient(); + } ), + }; +} ); + +// Mock the API +const triggerWPCLIMutationMock = jest.fn( async () => { + return Promise.resolve( { + data: { + triggerWPCLICommandOnAppEnvironment: { + inputToken: 'test-token', + sshAuthentication: { + host: 'test-host', + port: 22, + username: 'test-user', + privateKey: 'test-key', + passphrase: 'test-passphrase', + }, + command: { + guid: 'test-guid', + }, + }, + }, + } ); +} ); + +jest.mock( '../../src/lib/api' ); +jest.mocked( API ).mockImplementation( + () => + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + ( { + mutate: triggerWPCLIMutationMock, + } as any ) +); + +// Mock tracker +const mockTracker = jest.fn() as CommandTracker; +jest.mock( '../../src/lib/tracker', () => ( { + makeCommandTracker: jest.fn( () => mockTracker ), +} ) ); + +describe( 'WPCommand', () => { + const app = { id: 123 }; + const env = { id: 456 }; + let cmd: WPCliCommandOverSSH; + + beforeEach( () => { + jest.clearAllMocks(); + cmd = new WPCliCommandOverSSH( app, env ); + } ); + + describe( 'run', () => { + it( 'should pass the correct arguments to the SSH connection when executing a command', async () => { + const dummyStream = new PassThrough(); + + mockExec.mockImplementation( ( ( + _cmd: string, + callback: ( err: undefined, stream: Stream ) => void + ) => { + callback( undefined, dummyStream ); + + // Simulate the SSH connection closing right after the command is executed + dummyStream.emit( 'close' ); + } ) as unknown as Client[ 'exec' ] ); + + await cmd.run( 'plugin list' ); + + expect( mockExec ).toHaveBeenCalledWith( + expect.stringMatching( + /GUID=test-guid INPUT_TOKEN=test-token VERSION=\S+ ROWS=\d+ COLUMNS=\d+ TTY=\S+/ + ), + expect.anything() + ); + + dummyStream.end(); + + expect( processExitMock ).not.toHaveBeenCalled(); + } ); + + it( 'should throw an error when SSH connection failed', async () => { + const dummyStream = new PassThrough(); + + mockExec.mockImplementation( ( ( + _cmd: string, + callback: ( err: Error, stream: Stream ) => void + ) => { + callback( new Error( 'ops!' ), dummyStream ); + } ) as unknown as Client[ 'exec' ] ); + + const result = cmd.run( 'plugin list' ); + + await expect( result ).rejects.toThrow( 'Process exited with code: 1' ); + + expect( consoleErrorMock ).toHaveBeenCalledWith( expect.stringMatching( /ops!/ ) ); + } ); + + it( 'should throw an error when wp-cli command returned a non-zero status code', async () => { + const dummyStream = new PassThrough(); + + mockExec.mockImplementation( ( ( + _cmd: string, + callback: ( err: undefined, stream: Stream ) => void + ) => { + callback( undefined, dummyStream ); + + // Simulate the SSH connection closing right after the command is executed + dummyStream.emit( 'exit', 23 ); + dummyStream.emit( 'close' ); + } ) as unknown as Client[ 'exec' ] ); + + const result = cmd.run( 'plugin list' ); + + await expect( result ).rejects.toThrow( 'Process exited with code: 23' ); + } ); + } ); +} ); diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 0b5343cd1..398e48993 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -41,6 +41,7 @@ "socket.io-client": "^4.5.3", "socket.io-stream": "npm:@wearemothership/socket.io-stream@^0.9.1", "socks-proxy-agent": "^5.0.1", + "ssh2": "1.16.0", "tar": "^7.4.0", "update-notifier": "7.3.1", "uuid": "11.1.0", @@ -127,6 +128,7 @@ "@types/proxy-from-env": "^1.0.4", "@types/semver": "^7.5.5", "@types/shelljs": "^0.8.15", + "@types/ssh2": "^1.15.4", "@types/tar": "^6.1.13", "@types/update-notifier": "^6.0.8", "@types/xml2js": "^0.4.14", @@ -3797,9 +3799,9 @@ } }, "node_modules/@types/ssh2": { - "version": "1.11.11", - "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.11.11.tgz", - "integrity": "sha512-LdnE7UBpvHCgUznvn2fwLt2hkaENcKPFqOyXGkvyTLfxCXBN6roc1RmECNYuzzbHePzD3PaAov5rri9hehzx9Q==", + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", + "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==", "dev": true, "dependencies": { "@types/node": "^18.11.18" @@ -16092,9 +16094,9 @@ } }, "@types/ssh2": { - "version": "1.11.11", - "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.11.11.tgz", - "integrity": "sha512-LdnE7UBpvHCgUznvn2fwLt2hkaENcKPFqOyXGkvyTLfxCXBN6roc1RmECNYuzzbHePzD3PaAov5rri9hehzx9Q==", + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", + "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==", "dev": true, "requires": { "@types/node": "^18.11.18" diff --git a/package.json b/package.json index 680e53995..dae8e3531 100644 --- a/package.json +++ b/package.json @@ -127,6 +127,7 @@ "@types/proxy-from-env": "^1.0.4", "@types/semver": "^7.5.5", "@types/shelljs": "^0.8.15", + "@types/ssh2": "^1.15.4", "@types/tar": "^6.1.13", "@types/update-notifier": "^6.0.8", "@types/xml2js": "^0.4.14", @@ -170,6 +171,7 @@ "socket.io-client": "^4.5.3", "socket.io-stream": "npm:@wearemothership/socket.io-stream@^0.9.1", "socks-proxy-agent": "^5.0.1", + "ssh2": "1.16.0", "tar": "^7.4.0", "update-notifier": "7.3.1", "uuid": "11.1.0", diff --git a/src/bin/vip-wp.js b/src/bin/vip-wp.js index 9a2cbda43..3e6cd1f8b 100755 --- a/src/bin/vip-wp.js +++ b/src/bin/vip-wp.js @@ -8,6 +8,7 @@ import SocketIO from 'socket.io-client'; import IOStream from 'socket.io-stream'; import { Writable } from 'stream'; +import { WPCliCommandOverSSH } from '../commands/wp-ssh'; import API, { API_HOST, disableGlobalGraphQLErrorHandling } from '../lib/api'; import commandWrapper, { getEnvIdentifier } from '../lib/cli/command'; import * as exit from '../lib/cli/exit'; @@ -29,6 +30,7 @@ const appQuery = `id, name, appId type name + wpcliStrategy primaryDomain { name } @@ -368,6 +370,12 @@ commandWrapper( { let countSIGINT = 0; + if ( opts.env.wpcliStrategy === 'ssh' ) { + const wpCommandRunner = new WPCliCommandOverSSH( opts.app, opts.env ); + await wpCommandRunner.run( cmd, { command: commandForAnalytics } ); + return; + } + const mutableStdout = new Writable( { write( chunk, encoding, callback ) { if ( ! this.muted ) { diff --git a/src/commands/wp-ssh.ts b/src/commands/wp-ssh.ts new file mode 100644 index 000000000..751bc507c --- /dev/null +++ b/src/commands/wp-ssh.ts @@ -0,0 +1,277 @@ +/** + * External dependencies + */ +import chalk from 'chalk'; +import debugLib from 'debug'; +import gql from 'graphql-tag'; +import * as ssh2 from 'ssh2'; + +/** + * Internal dependencies + */ +import pkg from '../../package.json'; +import { App, AppEnvironment } from '../graphqlTypes'; +import API from '../lib/api'; +import { CommandTracker, makeCommandTracker } from '../lib/tracker'; + +const debug = debugLib( '@automattic/vip:wp/ssh' ); + +const NON_TTY_COLUMNS = 100; +const NON_TTY_ROWS = 15; + +const SSH_HANDSHAKE_TIMEOUT_MS = 5000; + +interface TriggerWPCLICommandMutationResponse { + triggerWPCLICommandOnAppEnvironment: { + inputToken: string; + sshAuthentication: { + host: string; + port: number; + username: string; + privateKey: string; + passphrase: string; + }; + command: { + guid: string; + }; + }; +} + +const TRIGGER_WP_CLI_COMMAND_MUTATION = gql` + mutation TriggerWPCLICommandMutation($input: AppEnvironmentTriggerWPCLICommandInput) { + triggerWPCLICommandOnAppEnvironment(input: $input) { + inputToken + sshAuthentication { + host + port + username + privateKey + passphrase + } + command { + guid + } + } + } +`; + +export class NonZeroExitCodeError extends Error { + public exitCode: number; + + constructor( message: string, exitCode: number ) { + super( message ); + this.exitCode = exitCode; + } +} + +export class WPCliCommandOverSSH { + private app: App; + private env: AppEnvironment; + private track: CommandTracker; + + constructor( app: App, env: AppEnvironment ) { + this.app = app; + this.env = env; + + this.track = makeCommandTracker( 'wp', { + app: this.app.id, + env: this.env.id, + execution_type: 'ssh', + } ); + } + + public async run( + command: string, + extraTrackingInfo: Record< string, unknown > = {} + ): Promise< void > { + if ( ! this.app.id || ! this.env.id ) { + await this.track( 'error', { + error: 'no_app_env_id', + message: 'No app or env ID provided', + ...extraTrackingInfo, + } ); + + throw new Error( 'No app ID or environment ID provided' ); + } + + debug( "Requesting SSH authentication for command '%s'", command ); + + const sshAuth = await this.getSSHAuthForCommand( command, extraTrackingInfo ); + + const data = sshAuth.data?.triggerWPCLICommandOnAppEnvironment; + + if ( ! data ) { + await this.track( 'error', { + error: 'no_ssh_auth_data', + message: 'No SSH authentication data received', + ...extraTrackingInfo, + } ); + + throw new Error( 'WP-CLI SSH Authentication failed' ); + } + + debug( 'Connecting to SSH', { + host: data.sshAuthentication.host, + port: data.sshAuthentication.port, + username: data.sshAuthentication.username, + guid: data.command.guid, + } ); + + try { + await this.executeCommandOverSSH( { + host: data.sshAuthentication.host, + port: data.sshAuthentication.port, + username: data.sshAuthentication.username, + privateKey: data.sshAuthentication.privateKey, + passphrase: data.sshAuthentication.passphrase, + guid: data.command.guid, + inputToken: data.inputToken, + } ); + + await this.track( 'success', { guid: data?.command.guid } ); + } catch ( err ) { + if ( err instanceof NonZeroExitCodeError ) { + await this.track( 'error', { + guid: data?.command.guid, + error: 'non_zero_exit_code', + message: `Command failed with exit code ${ err.exitCode }`, + ...extraTrackingInfo, + } ); + + process.exit( err.exitCode ); + } + const message = err instanceof Error ? err.message : String( err ); + + console.error( `${ chalk.red( 'Error:' ) } ${ message }` ); + + await this.track( 'error', { + guid: data?.command.guid, + error: 'ssh_command_failed', + message: 'Error executing command over SSH', + ...extraTrackingInfo, + } ); + + process.exit( 1 ); + } + } + + private async executeCommandOverSSH( { + host, + port, + username, + privateKey, + passphrase, + guid, + inputToken, + }: { + host: string; + port: number; + username: string; + privateKey: string; + passphrase: string; + guid: string; + inputToken: string; + } ) { + return new Promise< void >( ( resolve, reject ) => { + const conn = new ssh2.Client(); + + const columns = process.stdout.columns || NON_TTY_COLUMNS; + const rows = process.stdout.rows || NON_TTY_ROWS; + const isTTY = process.stdout.isTTY ? 'true' : 'false'; + + let commandStarted = false; + + conn + .on( 'ready', () => { + conn.exec( + `GUID=${ guid } INPUT_TOKEN=${ inputToken } VERSION=${ pkg.version } ROWS=${ rows } COLUMNS=${ columns } TTY=${ isTTY }`, + ( err, stream ) => { + if ( err ) { + reject( err ); + return; + } + + stream.on( 'exit', ( exitCode: number ) => { + if ( exitCode !== 0 ) { + reject( new NonZeroExitCodeError( `Command failed`, exitCode ) ); + } + } ); + + commandStarted = true; + + const handleSIGINT = () => { + process.removeListener( 'SIGINT', handleSIGINT ); + console.log( 'SIGINT received. Canceling command...' ); + stream.end( '\x03' ); + }; + process.on( 'SIGINT', handleSIGINT ); + + const handleSIGTERM = () => { + process.removeListener( 'SIGTERM', handleSIGTERM ); + console.log( 'SIGTERM received. Canceling command...' ); + stream.end( '\x1F' ); + }; + process.on( 'SIGTERM', handleSIGTERM ); + + stream.pipe( process.stdout ); + process.stdin.pipe( stream ); + + stream.on( 'close', () => { + process.removeListener( 'SIGINT', handleSIGINT ); + process.removeListener( 'SIGTERM', handleSIGTERM ); + + conn.end(); + resolve(); + } ); + } + ); + } ) + .on( 'error', err => { + reject( err ); + } ) + .on( 'close', () => { + if ( ! commandStarted ) { + reject( new Error( 'Connection closed before command started' ) ); + } + } ) + .connect( { + host, + port, + username, + privateKey, + passphrase, + readyTimeout: SSH_HANDSHAKE_TIMEOUT_MS, + } ); + } ); + } + + private async getSSHAuthForCommand( + command: string, + extraTrackingInfo: Record< string, unknown > | undefined + ) { + const api = API(); + + try { + return api.mutate< TriggerWPCLICommandMutationResponse >( { + mutation: TRIGGER_WP_CLI_COMMAND_MUTATION, + variables: { + input: { + id: this.app.id, + environmentId: this.env.id, + command, + }, + }, + } ); + } catch ( error ) { + const message = error instanceof Error ? error.message : String( error ); + + await this.track( 'error', { + error: 'trigger_failed', + message, + ...extraTrackingInfo, + } ); + + throw new Error( `Unable to trigger the WP-CLI command` ); + } + } +} diff --git a/src/graphqlTypes.d.ts b/src/graphqlTypes.d.ts index 08072a5fa..3a55de34d 100644 --- a/src/graphqlTypes.d.ts +++ b/src/graphqlTypes.d.ts @@ -21,10 +21,8 @@ export type Scalars = { Int: { input: number; output: number }; Float: { input: number; output: number }; BigInt: { input: any; output: any }; - /** Date custom scalar type */ Date: { input: any; output: any }; JSON: { input: any; output: any }; - /** MediaImportAllowedFileTypes scalar type */ MediaImportAllowedFileTypes: { input: any; output: any }; }; @@ -264,6 +262,7 @@ export type AppEnvironment = { wpSites?: Maybe< WpSiteList >; /** Get WordPress Site Details from SDS */ wpSitesSDS?: Maybe< WpSiteList >; + wpcliStrategy?: Maybe< AppEnvironmentWpCliStrategy >; }; export type AppEnvironmentAnomalyContextArgs = { @@ -1009,7 +1008,6 @@ export type AppEnvironmentNewRelic = { dashboardUrl?: Maybe< Scalars[ 'String' ][ 'output' ] >; deactivationTimestamp?: Maybe< Scalars[ 'String' ][ 'output' ] >; enabled?: Maybe< Scalars[ 'Boolean' ][ 'output' ] >; - isMaintenanceMode?: Maybe< Scalars[ 'Boolean' ][ 'output' ] >; isSetupComplete?: Maybe< Scalars[ 'Boolean' ][ 'output' ] >; samplingPercentage?: Maybe< Scalars[ 'BigInt' ][ 'output' ] >; users?: Maybe< AppEnvironmentNewRelicUsersList >; @@ -1270,6 +1268,7 @@ export type AppEnvironmentTriggerWpcliCommandPayload = { command?: Maybe< WpcliCommand >; /** The token for authenticating the socket connection */ inputToken?: Maybe< Scalars[ 'String' ][ 'output' ] >; + sshAuthentication?: Maybe< WpCliSshAuthentication >; }; export type AppEnvironmentUpdateSubsiteDomainInput = { @@ -1294,6 +1293,8 @@ export type AppEnvironmentUpdateSubsiteDomainStatus = { updateSubsiteDomainInProgress?: Maybe< Scalars[ 'Boolean' ][ 'output' ] >; }; +export type AppEnvironmentWpCliStrategy = 'ssh' | 'websocket'; + export type AppFeatureInput = { context?: InputMaybe< Scalars[ 'String' ][ 'input' ] >; id?: InputMaybe< Scalars[ 'Int' ][ 'input' ] >; @@ -2565,6 +2566,7 @@ export type Me = { samlOrganizationId?: Maybe< Scalars[ 'Int' ][ 'output' ] >; shouldBeVIP?: Maybe< Scalars[ 'Boolean' ][ 'output' ] >; tokens?: Maybe< Array< Maybe< Token > > >; + trackingUserId?: Maybe< Scalars[ 'String' ][ 'output' ] >; vipAuthId?: Maybe< Scalars[ 'String' ][ 'output' ] >; wpcomUsername?: Maybe< Scalars[ 'String' ][ 'output' ] >; }; @@ -4080,6 +4082,7 @@ export type UserApplicationRolesArgs = { export type UserOrganizationRolesArgs = { organizationId?: InputMaybe< Scalars[ 'Int' ][ 'input' ] >; + roleId?: InputMaybe< Scalars[ 'String' ][ 'input' ] >; }; export type UserApplicationRole = Model & { @@ -4237,6 +4240,15 @@ export type WpcliCommandUser = { wpcomUsername?: Maybe< Scalars[ 'String' ][ 'output' ] >; }; +export type WpCliSshAuthentication = { + __typename?: 'WPCliSSHAuthentication'; + host: Scalars[ 'String' ][ 'output' ]; + passphrase: Scalars[ 'String' ][ 'output' ]; + port: Scalars[ 'String' ][ 'output' ]; + privateKey: Scalars[ 'String' ][ 'output' ]; + username: Scalars[ 'String' ][ 'output' ]; +}; + export type WpInstallation = { __typename?: 'WPInstallation'; /** Core WordPress Site Installation Details */