Skip to content

Commit 37adbd9

Browse files
luismulinariabdullah-kasimmjangda
authored
Add ability to run WP-CLI commands over SSH (#2327)
* Add ability to run WP-CLI commands over SSH * Update src/commands/wp.ts Co-authored-by: abdullah-kasim <abdullah.kasim@automattic.com> * Update src/commands/wp.ts Co-authored-by: Mohammad Jangda <mo@automattic.com> * Apply suggestions from code review; Add tests * Update types * Apply suggestions from code review Co-authored-by: Mohammad Jangda <mo@automattic.com> * Apply suggestions from code review --------- Co-authored-by: abdullah-kasim <abdullah.kasim@automattic.com> Co-authored-by: Mohammad Jangda <mo@automattic.com>
1 parent e0db755 commit 37adbd9

File tree

6 files changed

+470
-9
lines changed

6 files changed

+470
-9
lines changed

__tests__/commands/wp-ssh.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
3+
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
4+
import Stream from 'node:stream';
5+
import { Client } from 'ssh2';
6+
import { PassThrough } from 'stream';
7+
8+
import { WPCliCommandOverSSH } from '../../src/commands/wp-ssh';
9+
import API from '../../src/lib/api';
10+
import { CommandTracker } from '../../src/lib/tracker';
11+
12+
const processExitMock = jest
13+
.spyOn( process, 'exit' )
14+
.mockImplementation( ( code?: string | number | null | undefined ) => {
15+
throw new Error( `Process exited with code: ${ code }` );
16+
} );
17+
18+
const consoleErrorMock = jest.spyOn( console, 'error' ).mockImplementation( () => {} );
19+
20+
const mockExec = jest.fn< Client[ 'exec' ] >();
21+
const mockEnd = jest.fn< Client[ 'end' ] >();
22+
23+
const EventEmitter = jest.requireActual< typeof import('events') >( 'events' );
24+
25+
class MockClient extends EventEmitter {
26+
public connect() {
27+
this.emit( 'ready' );
28+
29+
return this;
30+
}
31+
32+
public exec = mockExec;
33+
34+
public end = mockEnd;
35+
}
36+
37+
jest.mock( 'ssh2', () => {
38+
const original = jest.requireActual< typeof import('ssh2') >( 'ssh2' );
39+
40+
return {
41+
...original,
42+
__esModule: true,
43+
44+
Client: jest.fn().mockImplementation( () => {
45+
return new MockClient();
46+
} ),
47+
};
48+
} );
49+
50+
// Mock the API
51+
const triggerWPCLIMutationMock = jest.fn( async () => {
52+
return Promise.resolve( {
53+
data: {
54+
triggerWPCLICommandOnAppEnvironment: {
55+
inputToken: 'test-token',
56+
sshAuthentication: {
57+
host: 'test-host',
58+
port: 22,
59+
username: 'test-user',
60+
privateKey: 'test-key',
61+
passphrase: 'test-passphrase',
62+
},
63+
command: {
64+
guid: 'test-guid',
65+
},
66+
},
67+
},
68+
} );
69+
} );
70+
71+
jest.mock( '../../src/lib/api' );
72+
jest.mocked( API ).mockImplementation(
73+
() =>
74+
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
75+
( {
76+
mutate: triggerWPCLIMutationMock,
77+
} as any )
78+
);
79+
80+
// Mock tracker
81+
const mockTracker = jest.fn() as CommandTracker;
82+
jest.mock( '../../src/lib/tracker', () => ( {
83+
makeCommandTracker: jest.fn( () => mockTracker ),
84+
} ) );
85+
86+
describe( 'WPCommand', () => {
87+
const app = { id: 123 };
88+
const env = { id: 456 };
89+
let cmd: WPCliCommandOverSSH;
90+
91+
beforeEach( () => {
92+
jest.clearAllMocks();
93+
cmd = new WPCliCommandOverSSH( app, env );
94+
} );
95+
96+
describe( 'run', () => {
97+
it( 'should pass the correct arguments to the SSH connection when executing a command', async () => {
98+
const dummyStream = new PassThrough();
99+
100+
mockExec.mockImplementation( ( (
101+
_cmd: string,
102+
callback: ( err: undefined, stream: Stream ) => void
103+
) => {
104+
callback( undefined, dummyStream );
105+
106+
// Simulate the SSH connection closing right after the command is executed
107+
dummyStream.emit( 'close' );
108+
} ) as unknown as Client[ 'exec' ] );
109+
110+
await cmd.run( 'plugin list' );
111+
112+
expect( mockExec ).toHaveBeenCalledWith(
113+
expect.stringMatching(
114+
/GUID=test-guid INPUT_TOKEN=test-token VERSION=\S+ ROWS=\d+ COLUMNS=\d+ TTY=\S+/
115+
),
116+
expect.anything()
117+
);
118+
119+
dummyStream.end();
120+
121+
expect( processExitMock ).not.toHaveBeenCalled();
122+
} );
123+
124+
it( 'should throw an error when SSH connection failed', async () => {
125+
const dummyStream = new PassThrough();
126+
127+
mockExec.mockImplementation( ( (
128+
_cmd: string,
129+
callback: ( err: Error, stream: Stream ) => void
130+
) => {
131+
callback( new Error( 'ops!' ), dummyStream );
132+
} ) as unknown as Client[ 'exec' ] );
133+
134+
const result = cmd.run( 'plugin list' );
135+
136+
await expect( result ).rejects.toThrow( 'Process exited with code: 1' );
137+
138+
expect( consoleErrorMock ).toHaveBeenCalledWith( expect.stringMatching( /ops!/ ) );
139+
} );
140+
141+
it( 'should throw an error when wp-cli command returned a non-zero status code', async () => {
142+
const dummyStream = new PassThrough();
143+
144+
mockExec.mockImplementation( ( (
145+
_cmd: string,
146+
callback: ( err: undefined, stream: Stream ) => void
147+
) => {
148+
callback( undefined, dummyStream );
149+
150+
// Simulate the SSH connection closing right after the command is executed
151+
dummyStream.emit( 'exit', 23 );
152+
dummyStream.emit( 'close' );
153+
} ) as unknown as Client[ 'exec' ] );
154+
155+
const result = cmd.run( 'plugin list' );
156+
157+
await expect( result ).rejects.toThrow( 'Process exited with code: 23' );
158+
} );
159+
} );
160+
} );

npm-shrinkwrap.json

Lines changed: 8 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@
127127
"@types/proxy-from-env": "^1.0.4",
128128
"@types/semver": "^7.5.5",
129129
"@types/shelljs": "^0.8.15",
130+
"@types/ssh2": "^1.15.4",
130131
"@types/tar": "^6.1.13",
131132
"@types/update-notifier": "^6.0.8",
132133
"@types/xml2js": "^0.4.14",
@@ -170,6 +171,7 @@
170171
"socket.io-client": "^4.5.3",
171172
"socket.io-stream": "npm:@wearemothership/socket.io-stream@^0.9.1",
172173
"socks-proxy-agent": "^5.0.1",
174+
"ssh2": "1.16.0",
173175
"tar": "^7.4.0",
174176
"update-notifier": "7.3.1",
175177
"uuid": "11.1.0",

src/bin/vip-wp.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import SocketIO from 'socket.io-client';
88
import IOStream from 'socket.io-stream';
99
import { Writable } from 'stream';
1010

11+
import { WPCliCommandOverSSH } from '../commands/wp-ssh';
1112
import API, { API_HOST, disableGlobalGraphQLErrorHandling } from '../lib/api';
1213
import commandWrapper, { getEnvIdentifier } from '../lib/cli/command';
1314
import * as exit from '../lib/cli/exit';
@@ -29,6 +30,7 @@ const appQuery = `id, name,
2930
appId
3031
type
3132
name
33+
wpcliStrategy
3234
primaryDomain {
3335
name
3436
}
@@ -368,6 +370,12 @@ commandWrapper( {
368370

369371
let countSIGINT = 0;
370372

373+
if ( opts.env.wpcliStrategy === 'ssh' ) {
374+
const wpCommandRunner = new WPCliCommandOverSSH( opts.app, opts.env );
375+
await wpCommandRunner.run( cmd, { command: commandForAnalytics } );
376+
return;
377+
}
378+
371379
const mutableStdout = new Writable( {
372380
write( chunk, encoding, callback ) {
373381
if ( ! this.muted ) {

0 commit comments

Comments
 (0)