Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion features/flags.feature
Original file line number Diff line number Diff line change
Expand Up @@ -272,5 +272,19 @@ Feature: Global flags
When I try `WP_CLI_STRICT_ARGS_MODE=1 wp --debug --ssh=/ --version`
Then STDERR should contain:
"""
Running SSH command: ssh -q -T WP_CLI_STRICT_ARGS_MODE=1 wp
Running SSH command: ssh -q '' -T 'WP_CLI_STRICT_ARGS_MODE=1 wp
"""

Scenario: SSH flag should support changing directories
When I try `wp --debug --ssh=wordpress:/my/path --version`
Then STDERR should contain:
"""
Running SSH command: ssh -q 'wordpress' -T 'cd '\''/my/path'\''; wp
"""

Scenario: SSH flag should support Docker
When I try `wp --debug --ssh=docker:user@wordpress --version`
Then STDERR should contain:
"""
Running SSH command: docker exec --user 'user' 'wordpress' sh -c
"""
2 changes: 1 addition & 1 deletion features/help.feature
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,7 @@ Feature: Get help about WP-CLI commands
"""
And STDERR should be empty

When I run `TERM=vt100 COLUMNS=40 wp help test-wordwrap my_command | wc -L`
When I run `TERM=vt100 COLUMNS=40 wp help test-wordwrap my_command | sed '/\-\-ssh/d' | wc -L`
Then STDOUT should be:
"""
40
Expand Down
110 changes: 78 additions & 32 deletions php/WP_CLI/Runner.php
Original file line number Diff line number Diff line change
Expand Up @@ -331,30 +331,24 @@ private function _run_command() {
}

/**
* Perform a command against a remote server over SSH
* Perform a command against a remote server over SSH (or a container using
* scheme of "docker" or "docker-compose").
*
* @param string $connection_string Passed connection string.
* @return void
*/
private function run_ssh_command( $ssh ) {
private function run_ssh_command( $connection_string ) {

WP_CLI::do_hook( 'before_ssh' );

// host OR host/path/to/wordpress OR host:port/path/to/wordpress
$bits = Utils\parse_ssh_url( $ssh );
$host = isset( $bits['host'] ) ? $bits['host'] : null;
$port = isset( $bits['port'] ) ? $bits['port'] : null;
$path = isset( $bits['path'] ) ? $bits['path'] : null;

WP_CLI::debug( 'SSH host: ' . $host, 'bootstrap' );
WP_CLI::debug( 'SSH port: ' . $port, 'bootstrap' );
WP_CLI::debug( 'SSH path: ' . $path, 'bootstrap' );

$is_tty = function_exists( 'posix_isatty' ) && posix_isatty( STDOUT );
$bits = Utils\parse_ssh_url( $connection_string );

$pre_cmd = getenv( 'WP_CLI_SSH_PRE_CMD' );
if ( $pre_cmd ) {
$pre_cmd = rtrim( $pre_cmd, ';' ) . '; ';
}
if ( $path ) {
$pre_cmd .= "cd {$path}; ";
if ( ! empty( $bits['path'] ) ) {
$pre_cmd .= 'cd ' . escapeshellarg( $bits['path'] ) . '; ';
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if it would be better to set/override the --path arg below.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you think it would be better?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder so many things; I should probably try to cut down. General uninformed feeling:

  1. Less code, takes advantage of built-in escaping below.
  2. Doing it "the WP-CLI way."
  3. Currently, if user supplies path in connection string AND --path arg, the --path arg "wins" even though it's not clear why or how.

After writing out point 3, though, I realize changing it would be rather arbitrary. If we wanted to be explicit, we could just drop support for path in the connection string and direct users to the --path arg.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for clarifying, @chriszarate.

At this point, my preference would be to keep the behavior as it exists. The original decision was intentional: mimic the behavior of ssh'ing to a specific directory, and then running the wp executable. This lets you use a project-specific wp-cli.yml if you have multiple projects on the server.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense.

}

$env_vars = '';
Expand Down Expand Up @@ -386,23 +380,8 @@ private function run_ssh_command( $ssh ) {
}
}

$unescaped_command = sprintf(
'ssh -q %s%s %s %s',
$port ? '-p ' . (int) $port . ' ' : '',
$host,
$is_tty ? '-t' : '-T',
$pre_cmd . $env_vars . $wp_binary . ' ' . implode( ' ', array_map( 'escapeshellarg', $wp_args ) )
);

WP_CLI::debug( 'Running SSH command: ' . $unescaped_command, 'bootstrap' );

$escaped_command = sprintf(
'ssh -q %s%s %s %s',
$port ? '-p ' . (int) $port . ' ' : '',
escapeshellarg( $host ),
$is_tty ? '-t' : '-T',
escapeshellarg( $pre_cmd . $wp_binary . ' ' . implode( ' ', array_map( 'escapeshellarg', $wp_args ) ) )
);
$wp_command = $pre_cmd . $env_vars . $wp_binary . ' ' . implode( ' ', array_map( 'escapeshellarg', $wp_args ) );
$escaped_command = $this->generate_ssh_command( $bits, $wp_command );

passthru( $escaped_command, $exit_code );
if ( 255 === $exit_code ) {
Expand All @@ -412,6 +391,73 @@ private function run_ssh_command( $ssh ) {
}
}

/**
* Generate a shell command from the parsed connection string.
*
* @param array $bits Parsed connection string.
* @param string $wp_command WP-CLI command to run.
* @return string
*/
private function generate_ssh_command( $bits, $wp_command ) {
$escaped_command = '';

// Set default values.
foreach ( array( 'scheme', 'user', 'host', 'port', 'path' ) as $bit ) {
if ( ! isset( $bits[ $bit ] ) ) {
$bits[ $bit ] = null;
}

WP_CLI::debug( 'SSH ' . $bit . ': ' . $bits[ $bit ], 'bootstrap' );
}

$is_tty = function_exists( 'posix_isatty' ) && posix_isatty( STDOUT );

if ( 'docker' === $bits['scheme'] ) {
$command = 'docker exec %s%s%s sh -c %s';

$escaped_command = sprintf(
$command,
$bits['user'] ? '--user ' . escapeshellarg( $bits['user'] ) . ' ' : '',
$is_tty ? '-t ' : '',
escapeshellarg( $bits['host'] ),
escapeshellarg( $wp_command )
);
}

if ( 'docker-compose' === $bits['scheme'] ) {
$command = 'docker-compose exec %s%s%s sh -c %s';

$escaped_command = sprintf(
$command,
$bits['user'] ? '--user ' . escapeshellarg( $bits['user'] ) . ' ' : '',
$is_tty ? '' : '-T ',
escapeshellarg( $bits['host'] ),
escapeshellarg( $wp_command )
);
}

// Default scheme is SSH.
if ( 'ssh' === $bits['scheme'] || null === $bits['scheme'] ) {
$command = 'ssh -q %s%s %s %s';

if ( $bits['user'] ) {
$bits['host'] = $bits['user'] . '@' . $bits['host'];
}

$escaped_command = sprintf(
$command,
$bits['port'] ? '-p ' . (int) $bits['port'] . ' ' : '',
escapeshellarg( $bits['host'] ),
$is_tty ? '-t' : '-T',
escapeshellarg( $wp_command )
);
}

WP_CLI::debug( 'Running SSH command: ' . $escaped_command, 'bootstrap' );

return $escaped_command;
}

/**
* Check whether a given command is disabled by the config
*
Expand Down
2 changes: 1 addition & 1 deletion php/commands/src/CLI_Command.php
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,7 @@ function param_dump( $_, $assoc_args ) {
*
* # Dump the list of installed commands.
* $ wp cli cmd-dump
* {"name":"wp","description":"Manage WordPress through the command-line.","longdesc":"\n\n## GLOBAL PARAMETERS\n\n --path=<path>\n Path to the WordPress files.\n\n --ssh=<ssh>\n Perform operation against a remote server over SSH.\n\n --url=<url>\n Pretend request came from given URL. In multisite, this argument is how the target site is specified. \n\n --user=<id|login|email>\n
* {"name":"wp","description":"Manage WordPress through the command-line.","longdesc":"\n\n## GLOBAL PARAMETERS\n\n --path=<path>\n Path to the WordPress files.\n\n --ssh=<ssh>\n Perform operation against a remote server over SSH (or a container using scheme of "docker" or "docker-compose").\n\n --url=<url>\n Pretend request came from given URL. In multisite, this argument is how the target site is specified. \n\n --user=<id|login|email>\n
*
* @subcommand cmd-dump
*/
Expand Down
6 changes: 3 additions & 3 deletions php/config-spec.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
),

'ssh' => array(
'runtime' => '=[<user>@]<host>[:<port>][<path>]',
'file' => '[<user>@]<host>[:<port>][<path>]',
'desc' => 'Perform operation against a remote server over SSH.',
'runtime' => '=[<scheme>:][<user>@]<host|container>[:<port>][<path>]',
'file' => '[<scheme>:][<user>@]<host|container>[:<port>][<path>]',
'desc' => 'Perform operation against a remote server over SSH (or a container using scheme of "docker" or "docker-compose").',
),

'http' => array(
Expand Down
14 changes: 10 additions & 4 deletions php/utils.php
Original file line number Diff line number Diff line change
Expand Up @@ -770,18 +770,24 @@ function get_temp_dir() {
* @return mixed
*/
function parse_ssh_url( $url, $component = -1 ) {
preg_match( '#^([^:/~]+)(:([\d]*))?((/|~)(.+))?$#', $url, $matches );
preg_match( '#^((docker|docker\-compose|ssh):)?(([^@:]+)@)?([^:/~]+)(:([\d]*))?((/|~)(.+))?$#', $url, $matches );
$bits = array();
foreach( array(
1 => 'host',
3 => 'port',
4 => 'path',
2 => 'scheme',
4 => 'user',
5 => 'host',
7 => 'port',
8 => 'path',
) as $i => $key ) {
if ( ! empty( $matches[ $i ] ) ) {
$bits[ $key ] = $matches[ $i ];
}
}
switch ( $component ) {
case PHP_URL_SCHEME:
return isset( $bits['scheme'] ) ? $bits['scheme'] : null;
case PHP_URL_USER:
return isset( $bits['user'] ) ? $bits['user'] : null;
case PHP_URL_HOST:
return isset( $bits['host'] ) ? $bits['host'] : null;
case PHP_URL_PATH:
Expand Down
78 changes: 78 additions & 0 deletions tests/test-utils.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ public function testParseSSHUrl() {
$this->assertEquals( array(
'host' => 'foo',
), Utils\parse_ssh_url( $testcase ) );
$this->assertEquals( null, Utils\parse_ssh_url( $testcase, PHP_URL_SCHEME ) );
$this->assertEquals( null, Utils\parse_ssh_url( $testcase, PHP_URL_USER ) );
$this->assertEquals( 'foo', Utils\parse_ssh_url( $testcase, PHP_URL_HOST ) );
$this->assertEquals( null, Utils\parse_ssh_url( $testcase, PHP_URL_PORT ) );
$this->assertEquals( null, Utils\parse_ssh_url( $testcase, PHP_URL_PATH ) );
Expand All @@ -68,6 +70,8 @@ public function testParseSSHUrl() {
$this->assertEquals( array(
'host' => 'foo.com',
), Utils\parse_ssh_url( $testcase ) );
$this->assertEquals( null, Utils\parse_ssh_url( $testcase, PHP_URL_SCHEME ) );
$this->assertEquals( null, Utils\parse_ssh_url( $testcase, PHP_URL_USER ) );
$this->assertEquals( 'foo.com', Utils\parse_ssh_url( $testcase, PHP_URL_HOST ) );
$this->assertEquals( null, Utils\parse_ssh_url( $testcase, PHP_URL_PORT ) );
$this->assertEquals( null, Utils\parse_ssh_url( $testcase, PHP_URL_PATH ) );
Expand All @@ -77,6 +81,8 @@ public function testParseSSHUrl() {
'host' => 'foo.com',
'port' => 2222,
), Utils\parse_ssh_url( $testcase ) );
$this->assertEquals( null, Utils\parse_ssh_url( $testcase, PHP_URL_SCHEME ) );
$this->assertEquals( null, Utils\parse_ssh_url( $testcase, PHP_URL_USER ) );
$this->assertEquals( 'foo.com', Utils\parse_ssh_url( $testcase, PHP_URL_HOST ) );
$this->assertEquals( 2222, Utils\parse_ssh_url( $testcase, PHP_URL_PORT ) );
$this->assertEquals( null, Utils\parse_ssh_url( $testcase, PHP_URL_PATH ) );
Expand All @@ -87,6 +93,8 @@ public function testParseSSHUrl() {
'port' => 2222,
'path' => '/path/to/dir',
), Utils\parse_ssh_url( $testcase ) );
$this->assertEquals( null, Utils\parse_ssh_url( $testcase, PHP_URL_SCHEME ) );
$this->assertEquals( null, Utils\parse_ssh_url( $testcase, PHP_URL_USER ) );
$this->assertEquals( 'foo.com', Utils\parse_ssh_url( $testcase, PHP_URL_HOST ) );
$this->assertEquals( 2222, Utils\parse_ssh_url( $testcase, PHP_URL_PORT ) );
$this->assertEquals( '/path/to/dir', Utils\parse_ssh_url( $testcase, PHP_URL_PATH ) );
Expand All @@ -96,13 +104,17 @@ public function testParseSSHUrl() {
'host' => 'foo.com',
'path' => '~/path/to/dir',
), Utils\parse_ssh_url( $testcase ) );
$this->assertEquals( null, Utils\parse_ssh_url( $testcase, PHP_URL_SCHEME ) );
$this->assertEquals( null, Utils\parse_ssh_url( $testcase, PHP_URL_USER ) );
$this->assertEquals( 'foo.com', Utils\parse_ssh_url( $testcase, PHP_URL_HOST ) );
$this->assertEquals( null, Utils\parse_ssh_url( $testcase, PHP_URL_PORT ) );
$this->assertEquals( '~/path/to/dir', Utils\parse_ssh_url( $testcase, PHP_URL_PATH ) );

// No host
$testcase = '~/path/to/dir';
$this->assertEquals( array(), Utils\parse_ssh_url( $testcase ) );
$this->assertEquals( null, Utils\parse_ssh_url( $testcase, PHP_URL_SCHEME ) );
$this->assertEquals( null, Utils\parse_ssh_url( $testcase, PHP_URL_USER ) );
$this->assertEquals( null, Utils\parse_ssh_url( $testcase, PHP_URL_HOST ) );
$this->assertEquals( null, Utils\parse_ssh_url( $testcase, PHP_URL_PORT ) );
$this->assertEquals( null, Utils\parse_ssh_url( $testcase, PHP_URL_PATH ) );
Expand All @@ -113,6 +125,8 @@ public function testParseSSHUrl() {
'host' => 'foo.com',
'path' => '~/path/to/dir',
), Utils\parse_ssh_url( $testcase ) );
$this->assertEquals( null, Utils\parse_ssh_url( $testcase, PHP_URL_SCHEME ) );
$this->assertEquals( null, Utils\parse_ssh_url( $testcase, PHP_URL_USER ) );
$this->assertEquals( 'foo.com', Utils\parse_ssh_url( $testcase, PHP_URL_HOST ) );
$this->assertEquals( null, Utils\parse_ssh_url( $testcase, PHP_URL_PORT ) );
$this->assertEquals( '~/path/to/dir', Utils\parse_ssh_url( $testcase, PHP_URL_PATH ) );
Expand All @@ -123,9 +137,73 @@ public function testParseSSHUrl() {
'path' => '~/path/to/dir',
'port' => '2222'
), Utils\parse_ssh_url( $testcase ) );
$this->assertEquals( null, Utils\parse_ssh_url( $testcase, PHP_URL_SCHEME ) );
$this->assertEquals( null, Utils\parse_ssh_url( $testcase, PHP_URL_USER ) );
$this->assertEquals( 'foo.com', Utils\parse_ssh_url( $testcase, PHP_URL_HOST ) );
$this->assertEquals( '2222', Utils\parse_ssh_url( $testcase, PHP_URL_PORT ) );
$this->assertEquals( '~/path/to/dir', Utils\parse_ssh_url( $testcase, PHP_URL_PATH ) );

// explicit scheme, user, host, path, no port
$testcase = 'ssh:bar@foo.com:~/path/to/dir';
$this->assertEquals( array(
'scheme' => 'ssh',
'user' => 'bar',
'host' => 'foo.com',
'path' => '~/path/to/dir',
), Utils\parse_ssh_url( $testcase ) );
$this->assertEquals( 'ssh', Utils\parse_ssh_url( $testcase, PHP_URL_SCHEME ) );
$this->assertEquals( 'bar', Utils\parse_ssh_url( $testcase, PHP_URL_USER ) );
$this->assertEquals( 'foo.com', Utils\parse_ssh_url( $testcase, PHP_URL_HOST ) );
$this->assertEquals( null, Utils\parse_ssh_url( $testcase, PHP_URL_PORT ) );
$this->assertEquals( '~/path/to/dir', Utils\parse_ssh_url( $testcase, PHP_URL_PATH ) );

// container scheme
$testcase = 'docker:wordpress';
$this->assertEquals( array(
'scheme' => 'docker',
'host' => 'wordpress',
), Utils\parse_ssh_url( $testcase ) );
$this->assertEquals( 'docker', Utils\parse_ssh_url( $testcase, PHP_URL_SCHEME ) );
$this->assertEquals( null, Utils\parse_ssh_url( $testcase, PHP_URL_USER ) );
$this->assertEquals( 'wordpress', Utils\parse_ssh_url( $testcase, PHP_URL_HOST ) );
$this->assertEquals( null, Utils\parse_ssh_url( $testcase, PHP_URL_PORT ) );
$this->assertEquals( null, Utils\parse_ssh_url( $testcase, PHP_URL_PATH ) );

// container scheme with user, and host
$testcase = 'docker:bar@wordpress';
$this->assertEquals( array(
'scheme' => 'docker',
'user' => 'bar',
'host' => 'wordpress',
), Utils\parse_ssh_url( $testcase ) );
$this->assertEquals( 'docker', Utils\parse_ssh_url( $testcase, PHP_URL_SCHEME ) );
$this->assertEquals( 'bar', Utils\parse_ssh_url( $testcase, PHP_URL_USER ) );
$this->assertEquals( 'wordpress', Utils\parse_ssh_url( $testcase, PHP_URL_HOST ) );
$this->assertEquals( null, Utils\parse_ssh_url( $testcase, PHP_URL_PORT ) );
$this->assertEquals( null, Utils\parse_ssh_url( $testcase, PHP_URL_PATH ) );

// container scheme with user, host, and path
$testcase = 'docker-compose:bar@wordpress:~/path/to/dir';
$this->assertEquals( array(
'scheme' => 'docker-compose',
'user' => 'bar',
'host' => 'wordpress',
'path' => '~/path/to/dir',
), Utils\parse_ssh_url( $testcase ) );
$this->assertEquals( 'docker-compose', Utils\parse_ssh_url( $testcase, PHP_URL_SCHEME ) );
$this->assertEquals( 'bar', Utils\parse_ssh_url( $testcase, PHP_URL_USER ) );
$this->assertEquals( 'wordpress', Utils\parse_ssh_url( $testcase, PHP_URL_HOST ) );
$this->assertEquals( null, Utils\parse_ssh_url( $testcase, PHP_URL_PORT ) );
$this->assertEquals( '~/path/to/dir', Utils\parse_ssh_url( $testcase, PHP_URL_PATH ) );

// unsupported scheme, should not match
$testcase = 'foo:bar';
$this->assertEquals( array(), Utils\parse_ssh_url( $testcase ) );
$this->assertEquals( null, Utils\parse_ssh_url( $testcase, PHP_URL_SCHEME ) );
$this->assertEquals( null, Utils\parse_ssh_url( $testcase, PHP_URL_USER ) );
$this->assertEquals( null, Utils\parse_ssh_url( $testcase, PHP_URL_HOST ) );
$this->assertEquals( null, Utils\parse_ssh_url( $testcase, PHP_URL_PORT ) );
$this->assertEquals( null, Utils\parse_ssh_url( $testcase, PHP_URL_PATH ) );
}

public function testParseStrToArgv() {
Expand Down