From 6305c6051442737ec0514fd08323056aba637fa3 Mon Sep 17 00:00:00 2001 From: Luis Henrique Mulinari Date: Fri, 18 Apr 2025 08:15:43 -0300 Subject: [PATCH 1/7] Add ability to run WP-CLI commands over SSH --- npm-shrinkwrap.json | 14 ++-- package.json | 2 + src/bin/vip-wp.js | 8 ++ src/commands/wp.ts | 190 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 208 insertions(+), 6 deletions(-) create mode 100644 src/commands/wp.ts diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 9225eeec5..63242e296 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 c5b705f28..1ab8e55f2 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..7e8c49d53 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'; 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 + isK8sResident primaryDomain { name } @@ -368,6 +370,12 @@ commandWrapper( { let countSIGINT = 0; + if ( ! opts.env.isK8sResident ) { + const wpCommandRunner = new WPCliCommandOverSSH( opts.app, opts.env ); + await wpCommandRunner.run( cmd ); + return; + } + const mutableStdout = new Writable( { write( chunk, encoding, callback ) { if ( ! this.muted ) { diff --git a/src/commands/wp.ts b/src/commands/wp.ts new file mode 100644 index 000000000..457a53bb1 --- /dev/null +++ b/src/commands/wp.ts @@ -0,0 +1,190 @@ +/** + * External dependencies + */ +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' ); + +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 + } + } + } +`; + +const getSSHAuthForCommand = async ( appId: number, envId: number, command: string ) => { + const api = API(); + + return api.mutate( { + mutation: TRIGGER_WP_CLI_COMMAND_MUTATION, + variables: { + input: { + id: appId, + environmentId: envId, + command, + }, + }, + } ) as Promise< { data: TriggerWPCLICommandMutationResponse } >; +}; + +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, + executionType: 'ssh', + } ); + } + + public async run( command: string ): Promise< void > { + if ( ! this.app.id || ! this.env.id ) { + console.error( 'No app ID or environment ID provided' ); + + await this.track( 'error', { + error: 'no_app_env_id', + message: 'No app or env ID provided', + } ); + return; + } + + debug( "Requesting SSH authentication for command '%s'", command ); + + const sshAuth = await getSSHAuthForCommand( this.app.id, this.env.id, command ); + + const data = sshAuth.data?.triggerWPCLICommandOnAppEnvironment; + + debug( 'Connecting to SSH' ); + + 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 ) { + console.log( err ); + await this.track( 'error', { + guid: data?.command.guid, + error: 'ssh_command_failed', + message: 'Error executing command over SSH', + } ); + } + } + + 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( ( resolve, reject ) => { + const conn = new ssh2.Client(); + + conn + .on( 'ready', () => { + conn.exec( + `GUID=${ guid } INPUT_TOKEN=${ inputToken } VERSION=${ pkg.version }`, + ( err, stream ) => { + if ( err ) throw err; + + // OpenSSH does not implement the method of signal passing, + // so we need to handle SIGINT and SIGTERM manually + // https://github.com/mscdex/ssh2/issues/165#issuecomment-51422980 + 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', () => { + conn.end(); + resolve( '' ); + } ); + } + ); + } ) + .on( 'error', err => { + reject( err ); + } ) + .connect( { + host, + port, + username, + privateKey, + passphrase, + } ); + } ); + } +} From dc7c16fab275130b2d1ecd541a87cdcecab4b511 Mon Sep 17 00:00:00 2001 From: Luis Henrique Mulinari Date: Tue, 22 Apr 2025 11:09:34 -0300 Subject: [PATCH 2/7] Update src/commands/wp.ts Co-authored-by: abdullah-kasim --- src/commands/wp.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/commands/wp.ts b/src/commands/wp.ts index 457a53bb1..fcbefd620 100644 --- a/src/commands/wp.ts +++ b/src/commands/wp.ts @@ -146,7 +146,10 @@ export class WPCliCommandOverSSH { conn.exec( `GUID=${ guid } INPUT_TOKEN=${ inputToken } VERSION=${ pkg.version }`, ( err, stream ) => { - if ( err ) throw err; + if ( err ) { + reject( err ); + return; + } // OpenSSH does not implement the method of signal passing, // so we need to handle SIGINT and SIGTERM manually From caffdeae330cbd86a926865909bfd74e834d344a Mon Sep 17 00:00:00 2001 From: Luis Henrique Mulinari Date: Tue, 22 Apr 2025 11:10:14 -0300 Subject: [PATCH 3/7] Update src/commands/wp.ts Co-authored-by: Mohammad Jangda --- src/commands/wp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/wp.ts b/src/commands/wp.ts index fcbefd620..a6f67dd10 100644 --- a/src/commands/wp.ts +++ b/src/commands/wp.ts @@ -76,7 +76,7 @@ export class WPCliCommandOverSSH { this.track = makeCommandTracker( 'wp', { app: this.app.id, env: this.env.id, - executionType: 'ssh', + execution_type: 'ssh', } ); } From 3ab374e0e888348265b7f96d1a0807d9974e76dc Mon Sep 17 00:00:00 2001 From: Luis Henrique Mulinari Date: Tue, 22 Apr 2025 20:01:19 -0300 Subject: [PATCH 4/7] Apply suggestions from code review; Add tests --- __tests__/commands/wp.ts | 160 +++++++++++++++++++++++++++++++++++++++ src/bin/vip-wp.js | 3 +- src/commands/wp.ts | 94 +++++++++++++++++++---- 3 files changed, 240 insertions(+), 17 deletions(-) create mode 100644 __tests__/commands/wp.ts diff --git a/__tests__/commands/wp.ts b/__tests__/commands/wp.ts new file mode 100644 index 000000000..6ced8603e --- /dev/null +++ b/__tests__/commands/wp.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'; +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( 'ops!' ); + + 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/src/bin/vip-wp.js b/src/bin/vip-wp.js index 7e8c49d53..bdf78a5d3 100755 --- a/src/bin/vip-wp.js +++ b/src/bin/vip-wp.js @@ -31,6 +31,7 @@ const appQuery = `id, name, type name isK8sResident + wpcliStrategy primaryDomain { name } @@ -370,7 +371,7 @@ commandWrapper( { let countSIGINT = 0; - if ( ! opts.env.isK8sResident ) { + if ( opts.env.wpcliStrategy === 'ssh' ) { const wpCommandRunner = new WPCliCommandOverSSH( opts.app, opts.env ); await wpCommandRunner.run( cmd ); return; diff --git a/src/commands/wp.ts b/src/commands/wp.ts index a6f67dd10..53e103be5 100644 --- a/src/commands/wp.ts +++ b/src/commands/wp.ts @@ -1,6 +1,7 @@ /** * External dependencies */ +import chalk from 'chalk'; import debugLib from 'debug'; import gql from 'graphql-tag'; import * as ssh2 from 'ssh2'; @@ -15,6 +16,11 @@ 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; @@ -52,7 +58,7 @@ const TRIGGER_WP_CLI_COMMAND_MUTATION = gql` const getSSHAuthForCommand = async ( appId: number, envId: number, command: string ) => { const api = API(); - return api.mutate( { + return api.mutate< TriggerWPCLICommandMutationResponse >( { mutation: TRIGGER_WP_CLI_COMMAND_MUTATION, variables: { input: { @@ -61,9 +67,18 @@ const getSSHAuthForCommand = async ( appId: number, envId: number, command: stri command, }, }, - } ) as Promise< { data: TriggerWPCLICommandMutationResponse } >; + } ); }; +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; @@ -82,13 +97,12 @@ export class WPCliCommandOverSSH { public async run( command: string ): Promise< void > { if ( ! this.app.id || ! this.env.id ) { - console.error( 'No app ID or environment ID provided' ); - await this.track( 'error', { error: 'no_app_env_id', message: 'No app or env ID provided', } ); - return; + + throw new Error( 'No app ID or environment ID provided' ); } debug( "Requesting SSH authentication for command '%s'", command ); @@ -97,7 +111,21 @@ export class WPCliCommandOverSSH { const data = sshAuth.data?.triggerWPCLICommandOnAppEnvironment; - debug( 'Connecting to SSH' ); + if ( ! data ) { + await this.track( 'error', { + error: 'no_ssh_auth_data', + message: 'No SSH authentication data received', + } ); + + 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( { @@ -112,12 +140,26 @@ export class WPCliCommandOverSSH { await this.track( 'success', { guid: data?.command.guid } ); } catch ( err ) { - console.log( 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 }`, + } ); + + 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', } ); + + throw new Error( message ); } } @@ -138,22 +180,33 @@ export class WPCliCommandOverSSH { guid: string; inputToken: string; } ) { - return new Promise( ( resolve, reject ) => { + 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 }`, + `GUID=${ guid } INPUT_TOKEN=${ inputToken } VERSION=${ pkg.version } ROWS=${ rows } COLUMNS=${ columns } TTY=${ isTTY }`, ( err, stream ) => { if ( err ) { - reject( err ); - return; - } + reject( err ); + return; + } + + stream.on( 'exit', ( exitCode: number ) => { + if ( exitCode !== 0 ) { + reject( new NonZeroExitCodeError( `Command failed`, exitCode ) ); + } + } ); + + commandStarted = true; - // OpenSSH does not implement the method of signal passing, - // so we need to handle SIGINT and SIGTERM manually - // https://github.com/mscdex/ssh2/issues/165#issuecomment-51422980 const handleSIGINT = () => { process.removeListener( 'SIGINT', handleSIGINT ); console.log( 'SIGINT received. Canceling command...' ); @@ -172,8 +225,11 @@ export class WPCliCommandOverSSH { process.stdin.pipe( stream ); stream.on( 'close', () => { + process.removeListener( 'SIGINT', handleSIGINT ); + process.removeListener( 'SIGTERM', handleSIGTERM ); + conn.end(); - resolve( '' ); + resolve(); } ); } ); @@ -181,12 +237,18 @@ export class WPCliCommandOverSSH { .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, } ); } ); } From 538096e833c5307e8920798d658c40208cf2dcdb Mon Sep 17 00:00:00 2001 From: Luis Henrique Mulinari Date: Wed, 23 Apr 2025 09:59:30 -0300 Subject: [PATCH 5/7] Update types --- src/graphqlTypes.d.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) 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 */ From 407b656421917b53098da5909b7210a2b0cc40b9 Mon Sep 17 00:00:00 2001 From: Luis Henrique Mulinari Date: Wed, 23 Apr 2025 16:05:27 -0300 Subject: [PATCH 6/7] Apply suggestions from code review Co-authored-by: Mohammad Jangda --- src/bin/vip-wp.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/bin/vip-wp.js b/src/bin/vip-wp.js index bdf78a5d3..dab840407 100755 --- a/src/bin/vip-wp.js +++ b/src/bin/vip-wp.js @@ -30,7 +30,6 @@ const appQuery = `id, name, appId type name - isK8sResident wpcliStrategy primaryDomain { name From 9f30de845d40f3d4df5856529f9b1355322b8008 Mon Sep 17 00:00:00 2001 From: Luis Henrique Mulinari Date: Wed, 23 Apr 2025 18:34:25 -0300 Subject: [PATCH 7/7] Apply suggestions from code review --- __tests__/commands/{wp.ts => wp-ssh.ts} | 4 +- src/bin/vip-wp.js | 4 +- src/commands/{wp.ts => wp-ssh.ts} | 58 +++++++++++++++++-------- 3 files changed, 44 insertions(+), 22 deletions(-) rename __tests__/commands/{wp.ts => wp-ssh.ts} (96%) rename src/commands/{wp.ts => wp-ssh.ts} (85%) diff --git a/__tests__/commands/wp.ts b/__tests__/commands/wp-ssh.ts similarity index 96% rename from __tests__/commands/wp.ts rename to __tests__/commands/wp-ssh.ts index 6ced8603e..c38e02fb1 100644 --- a/__tests__/commands/wp.ts +++ b/__tests__/commands/wp-ssh.ts @@ -5,7 +5,7 @@ import Stream from 'node:stream'; import { Client } from 'ssh2'; import { PassThrough } from 'stream'; -import { WPCliCommandOverSSH } from '../../src/commands/wp'; +import { WPCliCommandOverSSH } from '../../src/commands/wp-ssh'; import API from '../../src/lib/api'; import { CommandTracker } from '../../src/lib/tracker'; @@ -133,7 +133,7 @@ describe( 'WPCommand', () => { const result = cmd.run( 'plugin list' ); - await expect( result ).rejects.toThrow( 'ops!' ); + await expect( result ).rejects.toThrow( 'Process exited with code: 1' ); expect( consoleErrorMock ).toHaveBeenCalledWith( expect.stringMatching( /ops!/ ) ); } ); diff --git a/src/bin/vip-wp.js b/src/bin/vip-wp.js index dab840407..3e6cd1f8b 100755 --- a/src/bin/vip-wp.js +++ b/src/bin/vip-wp.js @@ -8,7 +8,7 @@ import SocketIO from 'socket.io-client'; import IOStream from 'socket.io-stream'; import { Writable } from 'stream'; -import { WPCliCommandOverSSH } from '../commands/wp'; +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'; @@ -372,7 +372,7 @@ commandWrapper( { if ( opts.env.wpcliStrategy === 'ssh' ) { const wpCommandRunner = new WPCliCommandOverSSH( opts.app, opts.env ); - await wpCommandRunner.run( cmd ); + await wpCommandRunner.run( cmd, { command: commandForAnalytics } ); return; } diff --git a/src/commands/wp.ts b/src/commands/wp-ssh.ts similarity index 85% rename from src/commands/wp.ts rename to src/commands/wp-ssh.ts index 53e103be5..751bc507c 100644 --- a/src/commands/wp.ts +++ b/src/commands/wp-ssh.ts @@ -55,21 +55,6 @@ const TRIGGER_WP_CLI_COMMAND_MUTATION = gql` } `; -const getSSHAuthForCommand = async ( appId: number, envId: number, command: string ) => { - const api = API(); - - return api.mutate< TriggerWPCLICommandMutationResponse >( { - mutation: TRIGGER_WP_CLI_COMMAND_MUTATION, - variables: { - input: { - id: appId, - environmentId: envId, - command, - }, - }, - } ); -}; - export class NonZeroExitCodeError extends Error { public exitCode: number; @@ -95,11 +80,15 @@ export class WPCliCommandOverSSH { } ); } - public async run( command: string ): Promise< void > { + 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' ); @@ -107,7 +96,7 @@ export class WPCliCommandOverSSH { debug( "Requesting SSH authentication for command '%s'", command ); - const sshAuth = await getSSHAuthForCommand( this.app.id, this.env.id, command ); + const sshAuth = await this.getSSHAuthForCommand( command, extraTrackingInfo ); const data = sshAuth.data?.triggerWPCLICommandOnAppEnvironment; @@ -115,6 +104,7 @@ export class WPCliCommandOverSSH { await this.track( 'error', { error: 'no_ssh_auth_data', message: 'No SSH authentication data received', + ...extraTrackingInfo, } ); throw new Error( 'WP-CLI SSH Authentication failed' ); @@ -145,6 +135,7 @@ export class WPCliCommandOverSSH { guid: data?.command.guid, error: 'non_zero_exit_code', message: `Command failed with exit code ${ err.exitCode }`, + ...extraTrackingInfo, } ); process.exit( err.exitCode ); @@ -157,9 +148,10 @@ export class WPCliCommandOverSSH { guid: data?.command.guid, error: 'ssh_command_failed', message: 'Error executing command over SSH', + ...extraTrackingInfo, } ); - throw new Error( message ); + process.exit( 1 ); } } @@ -252,4 +244,34 @@ export class WPCliCommandOverSSH { } ); } ); } + + 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` ); + } + } }