From 40d55ee07bf3bc0746873fa49a787b27a982bedd Mon Sep 17 00:00:00 2001 From: Anton Medvedev Date: Wed, 30 Oct 2024 22:49:44 +0100 Subject: [PATCH] Refactor run and runLocally --- docs/UPGRADE.md | 4 + docs/api.md | 46 ++++-- recipe/deploy/update_code.php | 6 +- recipe/provision.php | 8 +- recipe/provision/databases.php | 16 +- recipe/provision/php.php | 2 +- recipe/provision/user.php | 10 +- recipe/provision/website.php | 4 +- src/Command/RunCommand.php | 9 +- src/Documentation/ApiGen.php | 20 ++- src/ProcessRunner/ProcessRunner.php | 47 +++--- src/Ssh/RunParams.php | 28 ++++ src/Ssh/SshClient.php | 43 +++--- src/Support/helpers.php | 13 ++ src/Task/Context.php | 13 +- src/functions.php | 188 +++++++++++++----------- tests/legacy/recipe/env.php | 4 +- tests/legacy/recipe/named_arguments.php | 22 --- tests/src/FunctionsTest.php | 15 -- 19 files changed, 279 insertions(+), 219 deletions(-) create mode 100644 src/Ssh/RunParams.php delete mode 100644 tests/legacy/recipe/named_arguments.php diff --git a/docs/UPGRADE.md b/docs/UPGRADE.md index 741634c5e..781d0b61e 100644 --- a/docs/UPGRADE.md +++ b/docs/UPGRADE.md @@ -2,6 +2,10 @@ ## Upgrade from 7.x to 8.x +- `run()` and `runLocally()` doesn't accept `options` parameter anymore. Use named arguments instead. + - `no_throw` is now `nothrow`. + - `real_time_output` is now `forceOutput`. + - `idle_timeout` is now `idleTimeout`. ## Upgrade from 6.x to 7.x diff --git a/docs/api.md b/docs/api.md index f5b758189..80573f82f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -7,7 +7,7 @@ ## host() ```php -host(string ...$hostname) +host(string ...$hostname): Host ``` Defines a host or hosts. @@ -24,13 +24,18 @@ task('test', function () { ``` - ## localhost() ```php -localhost(string ...$hostnames) +localhost(string ...$hostnames): Localhost ``` +Define a local host. +Deployer will not connect to this host, but will execute commands locally instead. + +```php +localhost('ci'); // Alias and hostname will be "ci". +``` ## currentHost() @@ -85,7 +90,6 @@ import(__DIR__ . '/config/hosts.yaml'); ``` - ## desc() ```php @@ -98,7 +102,7 @@ Set task description. ## task() ```php -task(string $name, $body = null): Task +task(string $name, ?callable $body = null): Task ``` Define a new task and save to tasks list. @@ -110,12 +114,12 @@ Alternatively get a defined task. | Argument | Type | Comment | |---|---|---| | `$name` | `string` | Name of current task. | -| `$body` | `callable():void` or `array` or `null` | Callable task, array of other tasks names or nothing to get a defined tasks | +| `$body` | `?callable` | Callable task, array of other tasks names or nothing to get a defined tasks | ## before() ```php -before(string $task, $do) +before(string $task, string|callable $do): ?Task ``` Call that task before specified task runs. @@ -126,12 +130,12 @@ Call that task before specified task runs. | Argument | Type | Comment | |---|---|---| | `$task` | `string` | The task before $that should be run. | -| `$do` | `string` or `callable():void` | The task to be run. | +| `$do` | `string` or `callable` | The task to be run. | ## after() ```php -after(string $task, $do) +after(string $task, string|callable $do): ?Task ``` Call that task after specified task runs. @@ -142,12 +146,12 @@ Call that task after specified task runs. | Argument | Type | Comment | |---|---|---| | `$task` | `string` | The task after $that should be run. | -| `$do` | `string` or `callable():void` | The task to be run. | +| `$do` | `string` or `callable` | The task to be run. | ## fail() ```php -fail(string $task, $do) +fail(string $task, string|callable $do): ?Task ``` Setup which task run on failure of $task. @@ -159,7 +163,7 @@ When called multiple times for a task, previous fail() definitions will be overr | Argument | Type | Comment | |---|---|---| | `$task` | `string` | The task which need to fail so $that should be run. | -| `$do` | `string` or `callable():void` | The task to be run. | +| `$do` | `string` or `callable` | The task to be run. | ## option() @@ -187,6 +191,11 @@ cd(string $path): void Change the current working directory. +```php +cd('~/myapp'); +run('ls'); // Will run `ls` in ~/myapp. +``` + ## become() @@ -210,7 +219,7 @@ $restore(); // revert back to the previous user ## within() ```php -within(string $path, callable $callback) +within(string $path, callable $callback): mixed ``` Execute a callback within a specific directory and revert back to the initial working directory. @@ -220,7 +229,15 @@ Execute a callback within a specific directory and revert back to the initial wo ## run() ```php -run(string $command, ?array $options = [], ?int $timeout = null, ?int $idle_timeout = null, ?string $secret = null, ?array $env = null, ?bool $real_time_output = false, ?bool $no_throw = false): string +run( + string $command, + ?int $timeout = null, + ?int $idle_timeout = null, + ?string $secret = null, + ?array $env = null, + ?bool $real_time_output = false, + ?bool $no_throw = false, +): string ``` Executes given command on remote host. @@ -245,7 +262,6 @@ run("echo $path"); | Argument | Type | Comment | |---|---|---| | `$command` | `string` | Command to run on remote host. | -| `$options` | `array` or `null` | Array of options will override passed named arguments. | | `$timeout` | `int` or `null` | Sets the process timeout (max. runtime). The timeout in seconds (default: 300 sec; see {{default_timeout}}, `null` to disable). | | `$idle_timeout` | `int` or `null` | Sets the process idle timeout (max. time since last output) in seconds. | | `$secret` | `string` or `null` | Placeholder `%secret%` can be used in command. Placeholder will be replaced with this value and will not appear in any logs. | diff --git a/recipe/deploy/update_code.php b/recipe/deploy/update_code.php index 22b5250db..f22f724bb 100644 --- a/recipe/deploy/update_code.php +++ b/recipe/deploy/update_code.php @@ -88,7 +88,7 @@ start: // Clone the repository to a bare repo. run("[ -d $bare ] || mkdir -p $bare"); - run("[ -f $bare/HEAD ] || $git clone --mirror $repository $bare 2>&1", ['env' => $env]); + run("[ -f $bare/HEAD ] || $git clone --mirror $repository $bare 2>&1", env: $env); cd($bare); @@ -99,7 +99,7 @@ goto start; } - run("$git remote update 2>&1", ['env' => $env]); + run("$git remote update 2>&1", env: $env); // Copy to release_path. @@ -108,7 +108,7 @@ } elseif (get('update_code_strategy') === 'clone') { cd('{{release_path}}'); run("$git clone -l $bare ."); - run("$git remote set-url origin $repository", ['env' => $env]); + run("$git remote set-url origin $repository", env: $env); run("$git checkout --force $target"); } else { throw new ConfigurationException(parse("Unknown `update_code_strategy` option: {{update_code_strategy}}.")); diff --git a/recipe/provision.php b/recipe/provision.php index e70db1a3e..b7aa9cef7 100644 --- a/recipe/provision.php +++ b/recipe/provision.php @@ -124,14 +124,14 @@ set('remote_user', get('provision_user')); // PHP - run('apt-add-repository ppa:ondrej/php -y', ['env' => ['DEBIAN_FRONTEND' => 'noninteractive']]); + run('apt-add-repository ppa:ondrej/php -y', env: ['DEBIAN_FRONTEND' => 'noninteractive']); // Caddy run("curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor --yes -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg"); run("curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' > /etc/apt/sources.list.d/caddy-stable.list"); // Update - run('apt-get update', ['env' => ['DEBIAN_FRONTEND' => 'noninteractive']]); + run('apt-get update', env: ['DEBIAN_FRONTEND' => 'noninteractive']); }) ->oncePerNode() ->verbose(); @@ -139,7 +139,7 @@ desc('Upgrades all packages'); task('provision:upgrade', function () { set('remote_user', get('provision_user')); - run('apt-get upgrade -y', ['env' => ['DEBIAN_FRONTEND' => 'noninteractive'], 'timeout' => 900]); + run('apt-get upgrade -y', env: ['DEBIAN_FRONTEND' => 'noninteractive'], timeout: 900); }) ->oncePerNode() ->verbose(); @@ -174,7 +174,7 @@ 'uuid-runtime', 'whois', ]; - run('apt-get install -y ' . implode(' ', $packages), ['env' => ['DEBIAN_FRONTEND' => 'noninteractive'], 'timeout' => 900]); + run('apt-get install -y ' . implode(' ', $packages), env: ['DEBIAN_FRONTEND' => 'noninteractive'], timeout: 900); }) ->verbose() ->oncePerNode(); diff --git a/recipe/provision/databases.php b/recipe/provision/databases.php index cb325a195..d6e03f96f 100644 --- a/recipe/provision/databases.php +++ b/recipe/provision/databases.php @@ -38,9 +38,9 @@ desc('Provision MySQL'); task('provision:mysql', function () { - run('apt-get install -y mysql-server', ['env' => ['DEBIAN_FRONTEND' => 'noninteractive'], 'timeout' => 900]); - run("mysql --user=\"root\" -e \"CREATE USER IF NOT EXISTS '{{db_user}}'@'0.0.0.0' IDENTIFIED BY '%secret%';\"", ['secret' => get('db_password')]); - run("mysql --user=\"root\" -e \"CREATE USER IF NOT EXISTS '{{db_user}}'@'%' IDENTIFIED BY '%secret%';\"", ['secret' => get('db_password')]); + run('apt-get install -y mysql-server', env: ['DEBIAN_FRONTEND' => 'noninteractive'], timeout: 900); + run("mysql --user=\"root\" -e \"CREATE USER IF NOT EXISTS '{{db_user}}'@'0.0.0.0' IDENTIFIED BY '%secret%';\"", secret: get('db_password')); + run("mysql --user=\"root\" -e \"CREATE USER IF NOT EXISTS '{{db_user}}'@'%' IDENTIFIED BY '%secret%';\"", secret: get('db_password')); run("mysql --user=\"root\" -e \"GRANT ALL PRIVILEGES ON *.* TO '{{db_user}}'@'0.0.0.0' WITH GRANT OPTION;\""); run("mysql --user=\"root\" -e \"GRANT ALL PRIVILEGES ON *.* TO '{{db_user}}'@'%' WITH GRANT OPTION;\""); run("mysql --user=\"root\" -e \"FLUSH PRIVILEGES;\""); @@ -49,9 +49,9 @@ desc('Provision MariaDB'); task('provision:mariadb', function () { - run('apt-get install -y mariadb-server', ['env' => ['DEBIAN_FRONTEND' => 'noninteractive'], 'timeout' => 900]); - run("mysql --user=\"root\" -e \"CREATE USER IF NOT EXISTS '{{db_user}}'@'0.0.0.0' IDENTIFIED BY '%secret%';\"", ['secret' => get('db_password')]); - run("mysql --user=\"root\" -e \"CREATE USER IF NOT EXISTS '{{db_user}}'@'%' IDENTIFIED BY '%secret%';\"", ['secret' => get('db_password')]); + run('apt-get install -y mariadb-server', env: ['DEBIAN_FRONTEND' => 'noninteractive'], timeout: 900); + run("mysql --user=\"root\" -e \"CREATE USER IF NOT EXISTS '{{db_user}}'@'0.0.0.0' IDENTIFIED BY '%secret%';\"", secret: get('db_password')); + run("mysql --user=\"root\" -e \"CREATE USER IF NOT EXISTS '{{db_user}}'@'%' IDENTIFIED BY '%secret%';\"", secret: get('db_password')); run("mysql --user=\"root\" -e \"GRANT ALL PRIVILEGES ON *.* TO '{{db_user}}'@'0.0.0.0' WITH GRANT OPTION;\""); run("mysql --user=\"root\" -e \"GRANT ALL PRIVILEGES ON *.* TO '{{db_user}}'@'%' WITH GRANT OPTION;\""); run("mysql --user=\"root\" -e \"FLUSH PRIVILEGES;\""); @@ -60,8 +60,8 @@ desc('Provision PostgreSQL'); task('provision:postgresql', function () { - run('apt-get install -y postgresql postgresql-contrib', ['env' => ['DEBIAN_FRONTEND' => 'noninteractive'], 'timeout' => 900]); + run('apt-get install -y postgresql postgresql-contrib', env: ['DEBIAN_FRONTEND' => 'noninteractive'], timeout: 900); run("sudo -u postgres psql <<< $'CREATE DATABASE {{db_name}};'"); - run("sudo -u postgres psql <<< $'CREATE USER {{db_user}} WITH ENCRYPTED PASSWORD \'%secret%\';'", ['secret' => get('db_password')]); + run("sudo -u postgres psql <<< $'CREATE USER {{db_user}} WITH ENCRYPTED PASSWORD \'%secret%\';'", secret: get('db_password')); run("sudo -u postgres psql <<< $'GRANT ALL PRIVILEGES ON DATABASE {{db_name}} TO {{db_user}};'"); }); diff --git a/recipe/provision/php.php b/recipe/provision/php.php index cf53cb3b5..ef9aef3ac 100644 --- a/recipe/provision/php.php +++ b/recipe/provision/php.php @@ -33,7 +33,7 @@ "php$version-xml", "php$version-zip", ]; - run('apt-get install -y ' . implode(' ', $packages), ['env' => ['DEBIAN_FRONTEND' => 'noninteractive']]); + run('apt-get install -y ' . implode(' ', $packages), env: ['DEBIAN_FRONTEND' => 'noninteractive']); // Configure PHP-CLI run("sed -i 's/error_reporting = .*/error_reporting = E_ALL/' /etc/php/$version/cli/php.ini"); diff --git a/recipe/provision/user.php b/recipe/provision/user.php index dd49fb45d..14d5afa10 100644 --- a/recipe/provision/user.php +++ b/recipe/provision/user.php @@ -32,8 +32,8 @@ // Make color prompt. run("sed -i 's/#force_color_prompt=yes/force_color_prompt=yes/' /home/deployer/.bashrc"); - $password = run("mkpasswd -m sha-512 '%secret%'", ['secret' => get('sudo_password')]); - run("usermod --password '%secret%' deployer", ['secret' => $password]); + $password = run("mkpasswd -m sha-512 '%secret%'", secret: get('sudo_password')); + run("usermod --password '%secret%' deployer", secret: $password); // Copy root public key to deployer user so user can login without password. run('cp /root/.ssh/authorized_keys /home/deployer/.ssh/authorized_keys'); @@ -82,9 +82,5 @@ return; } - run('echo "$PUBLIC_KEY" >> /home/deployer/.ssh/authorized_keys', [ - 'env' => [ - 'PUBLIC_KEY' => $publicKeyContent, - ], - ]); + run('echo "$PUBLIC_KEY" >> /home/deployer/.ssh/authorized_keys', env: ['PUBLIC_KEY' => $publicKeyContent]); }); diff --git a/recipe/provision/website.php b/recipe/provision/website.php index 38d08c8a5..b7403d295 100644 --- a/recipe/provision/website.php +++ b/recipe/provision/website.php @@ -4,8 +4,6 @@ namespace Deployer; -use function Deployer\Support\escape_shell_argument; - set('domain', function () { return ask(' Domain: ', get('hostname')); }); @@ -40,7 +38,7 @@ if (test('[ -f Caddyfile ]')) { run("echo $'$caddyfile' > Caddyfile.new"); - $diff = run('diff -U5 --color=always Caddyfile Caddyfile.new', ['no_throw' => true]); + $diff = run('diff -U5 --color=always Caddyfile Caddyfile.new', nothrow: true); if (empty($diff)) { run('rm Caddyfile.new'); } else { diff --git a/src/Command/RunCommand.php b/src/Command/RunCommand.php index 07139c733..69f93f5c0 100644 --- a/src/Command/RunCommand.php +++ b/src/Command/RunCommand.php @@ -72,10 +72,11 @@ protected function execute(Input $input, Output $output): int cd($path); } } - run($command, [ - 'real_time_output' => true, - 'timeout' => intval($input->getOption('timeout')), - ]); + run( + $command, + timeout: intval($input->getOption('timeout')), + forceOutput: true, + ); }); foreach ($hosts as $host) { diff --git a/src/Documentation/ApiGen.php b/src/Documentation/ApiGen.php index 31793e1eb..2cd64b0f3 100644 --- a/src/Documentation/ApiGen.php +++ b/src/Documentation/ApiGen.php @@ -21,6 +21,7 @@ public function parse(string $source): void { $comment = ''; $params = ''; + $signature = ''; $source = str_replace("\r\n", "\n", $source); @@ -34,7 +35,7 @@ public function parse(string $source): void } if (str_starts_with($line, 'function')) { $signature = preg_replace('/^function\s+/', '', $line); - $funcName = preg_replace('/\(.+$/', '', $signature); + $funcName = preg_replace('/\(.*$/', '', $signature); $this->fns[] = [ 'comment' => $comment, 'params' => $params, @@ -43,7 +44,12 @@ public function parse(string $source): void ]; $comment = ''; $params = ''; - break; + + if (str_ends_with($signature, '(')) { + $state = 'params'; + } else { + $signature = ''; + } } break; @@ -68,6 +74,16 @@ public function parse(string $source): void } $comment .= preg_replace('/^\s\*\s?/', '', $line) . "\n"; break; + + case 'params': + if (preg_match('/^\).+\{$/', $line, $matches)) { + $signature .= "\n" . preg_replace('/\{$/', '', $line); + $this->fns[count($this->fns) - 1]['signature'] = $signature; + $state = 'root'; + } else { + $signature .= "\n" . $line; + } + break; } } } diff --git a/src/ProcessRunner/ProcessRunner.php b/src/ProcessRunner/ProcessRunner.php index ee64fb45d..be281edbd 100644 --- a/src/ProcessRunner/ProcessRunner.php +++ b/src/ProcessRunner/ProcessRunner.php @@ -14,10 +14,14 @@ use Deployer\Exception\TimeoutException; use Deployer\Host\Host; use Deployer\Logger\Logger; +use Deployer\Ssh\RunParams; use Symfony\Component\Process\Exception\ProcessFailedException; use Symfony\Component\Process\Exception\ProcessTimedOutException; use Symfony\Component\Process\Process; +use function Deployer\Support\deployer_root; +use function Deployer\Support\env_stringify; + class ProcessRunner { private Printer $pop; @@ -29,41 +33,44 @@ public function __construct(Printer $pop, Logger $logger) $this->logger = $logger; } - public function run(Host $host, string $command, array $config = []): string + public function run(Host $host, string $command, RunParams $params): string { - $defaults = [ - 'timeout' => $host->get('default_timeout', 300), - 'idle_timeout' => null, - 'cwd' => getenv('DEPLOYER_ROOT') !== false ? getenv('DEPLOYER_ROOT') : (defined('DEPLOYER_DEPLOY_FILE') ? dirname(DEPLOYER_DEPLOY_FILE) : null), - 'real_time_output' => false, - 'shell' => 'bash -s', - ]; - $config = array_merge($defaults, $config); - $this->pop->command($host, 'run', $command); - $terminalOutput = $this->pop->callback($host, $config['real_time_output']); + $terminalOutput = $this->pop->callback($host, $params->forceOutput); $callback = function ($type, $buffer) use ($host, $terminalOutput) { $this->logger->printBuffer($host, $type, $buffer); $terminalOutput($type, $buffer); }; - $command = str_replace('%secret%', $config['secret'] ?? '', $command); - $command = str_replace('%sudo_pass%', $config['sudo_pass'] ?? '', $command); + if (!empty($params->secrets)) { + foreach ($params->secrets as $key => $value) { + $command = str_replace('%' . $key . '%', $value, $command); + } + } - $process = Process::fromShellCommandline($config['shell']) - ->setInput($command) - ->setTimeout($config['timeout']) - ->setIdleTimeout($config['idle_timeout']); + if (!empty($params->env)) { + $env = env_stringify($params->env); + $command = "export $env; $command"; + } - if ($config['cwd'] !== null) { - $process->setWorkingDirectory($config['cwd']); + if (!empty($params->dotenv)) { + $command = "source $params->dotenv; $command"; } + $process = Process::fromShellCommandline($params->shell) + ->setInput($command) + ->setTimeout($params->timeout) + ->setIdleTimeout($params->idleTimeout) + ->setWorkingDirectory($params->cwd ?? deployer_root()); + try { $process->mustRun($callback); return $process->getOutput(); - } catch (ProcessFailedException $exception) { + } catch (ProcessFailedException) { + if ($params->nothrow) { + return ''; + } throw new RunException( $host, $command, diff --git a/src/Ssh/RunParams.php b/src/Ssh/RunParams.php new file mode 100644 index 000000000..c6df31cac --- /dev/null +++ b/src/Ssh/RunParams.php @@ -0,0 +1,28 @@ +secrets = array_merge($params->secrets ?? [], $secrets ?? []); + $params->timeout = $timeout ?? $params->timeout; + return $params; + } +} diff --git a/src/Ssh/SshClient.php b/src/Ssh/SshClient.php index 9cd19efd3..b4e618a84 100644 --- a/src/Ssh/SshClient.php +++ b/src/Ssh/SshClient.php @@ -19,6 +19,8 @@ use Symfony\Component\Process\Exception\ProcessTimedOutException; use Symfony\Component\Process\Process; +use function Deployer\Support\env_stringify; + class SshClient { private OutputInterface $output; @@ -32,16 +34,8 @@ public function __construct(OutputInterface $output, Printer $pop, Logger $logge $this->logger = $logger; } - public function run(Host $host, string $command, array $config = []): string + public function run(Host $host, string $command, RunParams $params): string { - $defaults = [ - 'timeout' => $host->get('default_timeout', 300), - 'idle_timeout' => null, - 'real_time_output' => false, - 'no_throw' => false, - ]; - $config = array_merge($defaults, $config); - $shellId = 'id$' . bin2hex(random_bytes(10)); $shellCommand = $host->getShell(); if ($host->has('become') && !empty($host->get('become'))) { @@ -59,30 +53,43 @@ public function run(Host $host, string $command, array $config = []): string $this->output->writeln("[$host] $sshString"); } + if (!empty($params->cwd)) { + $command = "cd $params->cwd && ($command)"; + } + + if (!empty($params->env)) { + $env = env_stringify($params->env); + $command = "export $env; $command"; + } + + if (!empty($params->secrets)) { + foreach ($params->secrets as $key => $value) { + $command = str_replace('%' . $key . '%', strval($value), $command); + } + } + $this->pop->command($host, 'run', $command); $this->logger->log("[{$host->getAlias()}] run $command"); - $command = str_replace('%secret%', strval($config['secret'] ?? ''), $command); - $command = str_replace('%sudo_pass%', strval($config['sudo_pass'] ?? ''), $command); $process = new Process($ssh); $process ->setInput($command) - ->setTimeout((null === $config['timeout']) ? null : (float) $config['timeout']) - ->setIdleTimeout((null === $config['idle_timeout']) ? null : (float) $config['idle_timeout']); + ->setTimeout($params->timeout) + ->setIdleTimeout($params->idleTimeout); - $callback = function ($type, $buffer) use ($config, $host) { + $callback = function ($type, $buffer) use ($params, $host) { $this->logger->printBuffer($host, $type, $buffer); - $this->pop->callback($host, boolval($config['real_time_output']))($type, $buffer); + $this->pop->callback($host, $params->forceOutput)($type, $buffer); }; try { $process->run($callback); } catch (ProcessTimedOutException $exception) { // Let's try to kill all processes started by this command. - $pid = $this->run($host, "ps x | grep $shellId | grep -v grep | awk '{print \$1}'"); + $pid = $this->run($host, "ps x | grep $shellId | grep -v grep | awk '{print \$1}'", $params->with(timeout: 10)); // Minus before pid means all processes in this group. - $this->run($host, "kill -9 -$pid"); + $this->run($host, "kill -9 -$pid", $params->with(timeout: 20)); throw new TimeoutException( $command, $exception->getExceededTimeout(), @@ -92,7 +99,7 @@ public function run(Host $host, string $command, array $config = []): string $output = $process->getOutput(); $exitCode = $process->getExitCode(); - if ($exitCode !== 0 && !$config['no_throw']) { + if ($exitCode !== 0 && !$params->nothrow) { throw new RunException( $host, $command, diff --git a/src/Support/helpers.php b/src/Support/helpers.php index 3ece771b0..1a91fe494 100644 --- a/src/Support/helpers.php +++ b/src/Support/helpers.php @@ -212,3 +212,16 @@ function escape_shell_argument(string $argument): string { return "'" . str_replace("'", "'\\''", $argument) . "'"; } + +function deployer_root(): string +{ + if (getenv('DEPLOYER_ROOT') !== false) { + return getenv('DEPLOYER_ROOT'); + } else { + if (defined('DEPLOYER_DEPLOY_FILE')) { + return dirname(DEPLOYER_DEPLOY_FILE); + } else { + return getcwd(); + } + } +} diff --git a/src/Task/Context.php b/src/Task/Context.php index 64d5cf3f2..510ddf662 100644 --- a/src/Task/Context.php +++ b/src/Task/Context.php @@ -18,15 +18,12 @@ class Context { - /** - * @var Host - */ - private $host; + private Host $host; /** * @var Context[] */ - private static $contexts = []; + private static array $contexts = []; public function __construct(Host $host) { @@ -43,11 +40,7 @@ public static function has(): bool return !empty(self::$contexts); } - /** - * @return Context|false - * @throws Exception - */ - public static function get() + public static function get(): Context { if (empty(self::$contexts)) { throw new Exception("Context was requested but was not available."); diff --git a/src/functions.php b/src/functions.php index 79a1ed3da..3901a61af 100644 --- a/src/functions.php +++ b/src/functions.php @@ -19,6 +19,7 @@ use Deployer\Host\Localhost; use Deployer\Host\Range; use Deployer\Importer\Importer; +use Deployer\Ssh\RunParams; use Deployer\Support\ObjectProxy; use Deployer\Task\Context; use Deployer\Task\GroupTask; @@ -33,7 +34,6 @@ use Symfony\Component\Console\Question\Question; use function Deployer\Support\array_merge_alternate; -use function Deployer\Support\env_stringify; use function Deployer\Support\is_closure; /** @@ -49,10 +49,8 @@ * $port = host('example.org')->get('port'); * }); * ``` - * - * @return Host|ObjectProxy */ -function host(string ...$hostname) +function host(string ...$hostname): Host|ObjectProxy { $deployer = Deployer::get(); if (count($hostname) === 1 && $deployer->hosts->has($hostname[0])) { @@ -77,14 +75,19 @@ function host(string ...$hostname) $deployer->hosts->set($hostname, $host); return $host; }, $aliases); - return new ObjectProxy($hosts); + return new ObjectProxy($hosts); // @phpstan-ignore-line Acts like a host. } } /** - * @return Localhost|ObjectProxy + * Define a local host. + * Deployer will not connect to this host, but will execute commands locally instead. + * + * ```php + * localhost('ci'); // Alias and hostname will be "ci". + * ``` */ -function localhost(string ...$hostnames) +function localhost(string ...$hostnames): Localhost|ObjectProxy { $deployer = Deployer::get(); $hostnames = Range::expand($hostnames); @@ -99,7 +102,7 @@ function localhost(string ...$hostnames) $deployer->hosts->set($host->getAlias(), $host); return $host; }, $hostnames); - return new ObjectProxy($hosts); + return new ObjectProxy($hosts); // @phpstan-ignore-line Acts like a host. } } @@ -151,8 +154,6 @@ function selectedHosts(): array * ```php * import(__DIR__ . '/config/hosts.yaml'); * ``` - * - * @throws Exception */ function import(string $file): void { @@ -179,9 +180,10 @@ function desc(?string $title = null): ?string * Alternatively get a defined task. * * @param string $name Name of current task. - * @param callable():void|array|null $body Callable task, array of other tasks names or nothing to get a defined tasks + * @param callable|array|null $body Callable task, array of other tasks names or nothing to get a defined tasks + * @return Task */ -function task(string $name, $body = null): Task +function task(string $name, callable|array|null $body = null): Task { $deployer = Deployer::get(); @@ -229,11 +231,11 @@ function task(string $name, $body = null): Task * Call that task before specified task runs. * * @param string $task The task before $that should be run. - * @param string|callable():void $do The task to be run. + * @param string|callable $do The task to be run. * - * @return Task|null + * @return ?Task */ -function before(string $task, $do) +function before(string $task, string|callable $do): ?Task { if (is_closure($do)) { $newTask = task("before:$task", $do); @@ -249,11 +251,11 @@ function before(string $task, $do) * Call that task after specified task runs. * * @param string $task The task after $that should be run. - * @param string|callable():void $do The task to be run. + * @param string|callable $do The task to be run. * - * @return Task|null + * @return ?Task */ -function after(string $task, $do) +function after(string $task, string|callable $do): ?Task { if (is_closure($do)) { $newTask = task("after:$task", $do); @@ -270,11 +272,11 @@ function after(string $task, $do) * When called multiple times for a task, previous fail() definitions will be overridden. * * @param string $task The task which need to fail so $that should be run. - * @param string|callable():void $do The task to be run. + * @param string|callable $do The task to be run. * - * @return Task|null + * @return ?Task */ -function fail(string $task, $do) +function fail(string $task, string|callable $do): ?Task { if (is_callable($do)) { $newTask = task("fail:$task", $do); @@ -305,6 +307,11 @@ function option(string $name, $shortcut = null, ?int $mode = null, string $descr /** * Change the current working directory. + * + * ```php + * cd('~/myapp'); + * run('ls'); // Will run `ls` in ~/myapp. + * ``` */ function cd(string $path): void { @@ -325,7 +332,6 @@ function cd(string $path): void * * @param string $user * @return \Closure - * @throws Exception */ function become(string $user): \Closure { @@ -339,10 +345,10 @@ function become(string $user): \Closure /** * Execute a callback within a specific directory and revert back to the initial working directory. * - * @return mixed|null Return value of the $callback function or null if callback doesn't return anything + * @return mixed Return value of the $callback function or null if callback doesn't return anything * @throws Exception */ -function within(string $path, callable $callback) +function within(string $path, callable $callback): mixed { $lastWorkingPath = get('working_path', ''); try { @@ -371,61 +377,62 @@ function within(string $path, callable $callback) * ``` * * @param string $command Command to run on remote host. - * @param array|null $options Array of options will override passed named arguments. + * @param string|null $cwd Sets the process working directory. If not set {{working_path}} will be used. * @param int|null $timeout Sets the process timeout (max. runtime). The timeout in seconds (default: 300 sec; see {{default_timeout}}, `null` to disable). - * @param int|null $idle_timeout Sets the process idle timeout (max. time since last output) in seconds. + * @param int|null $idleTimeout Sets the process idle timeout (max. time since last output) in seconds. * @param string|null $secret Placeholder `%secret%` can be used in command. Placeholder will be replaced with this value and will not appear in any logs. * @param array|null $env Array of environment variables: `run('echo $KEY', env: ['key' => 'value']);` - * @param bool|null $real_time_output Print command output in real-time. - * @param bool|null $no_throw Don't throw an exception of non-zero exit code. - * - * @throws Exception|RunException|TimeoutException - */ -function run(string $command, ?array $options = [], ?int $timeout = null, ?int $idle_timeout = null, ?string $secret = null, ?array $env = null, ?bool $real_time_output = false, ?bool $no_throw = false): string -{ - $namedArguments = []; - foreach (['timeout', 'idle_timeout', 'secret', 'env', 'real_time_output', 'no_throw'] as $arg) { - if ($$arg !== null) { - $namedArguments[$arg] = $$arg; - } + * @param bool|null $forceOutput Print command output in real-time. + * @param bool|null $nothrow Don't throw an exception of non-zero exit code. + * @return string + * @throws RunException + * @throws TimeoutException + * @throws WillAskUser + */ +function run( + string $command, + ?string $cwd = null, + ?array $env = null, + ?string $secret = null, + ?bool $nothrow = false, + ?bool $forceOutput = false, + ?int $timeout = null, + ?int $idleTimeout = null, +): string { + $runParams = new RunParams( + shell: currentHost()->getShell(), + cwd: $cwd ?? has('working_path') ? get('working_path') : null, + env: array_merge_alternate(get('env', []), $env ?? []), + nothrow: $nothrow, + timeout: $timeout ?? get('default_timeout', 300), + idleTimeout: $idleTimeout, + forceOutput: $forceOutput, + secrets: empty($secret) ? null : ['secret' => $secret], + ); + + $dotenv = get('dotenv', false); + if (!empty($dotenv)) { + $runParams->dotenv = $dotenv; } - $options = array_merge($namedArguments, $options); - $run = function ($command, $options = []): string { - $host = currentHost(); + $run = function (string $command, ?RunParams $params = null) use ($runParams): string { + $params = $params ?? $runParams; + $host = currentHost(); $command = parse($command); - $workingPath = get('working_path', ''); - - if (!empty($workingPath)) { - $command = "cd $workingPath && ($command)"; - } - - $env = array_merge_alternate(get('env', []), $options['env'] ?? []); - if (!empty($env)) { - $env = env_stringify($env); - $command = "export $env; $command"; - } - - $dotenv = get('dotenv', false); - if (!empty($dotenv)) { - $command = ". $dotenv; $command"; - } - if ($host instanceof Localhost) { $process = Deployer::get()->processRunner; - $output = $process->run($host, $command, $options); + $output = $process->run($host, $command, $params); } else { $client = Deployer::get()->sshClient; - $output = $client->run($host, $command, $options); + $output = $client->run($host, $command, $params); } - return rtrim($output); }; if (preg_match('/^sudo\b/', $command)) { try { - return $run($command, $options); - } catch (RunException $exception) { + return $run($command); + } catch (RunException) { $askpass = get('sudo_askpass', '/tmp/dep_sudo_pass'); $password = get('sudo_pass', false); if ($password === false) { @@ -435,12 +442,14 @@ function run(string $command, ?array $options = [], ?int $timeout = null, ?int $ $run("echo -e '#!/bin/sh\necho \"\$PASSWORD\"' > $askpass"); $run("chmod a+x $askpass"); $command = preg_replace('/^sudo\b/', 'sudo -A', $command); - $output = $run(" SUDO_ASKPASS=$askpass PASSWORD=%sudo_pass% $command", array_merge($options, ['sudo_pass' => escapeshellarg($password)])); + $output = $run(" SUDO_ASKPASS=$askpass PASSWORD=%sudo_pass% $command", $runParams->with( + secrets: ['sudo_pass' => escapeshellarg($password)], + )); $run("rm $askpass"); return $output; } } else { - return $run($command, $options); + return $run($command); } } @@ -456,36 +465,45 @@ function run(string $command, ?array $options = [], ?int $timeout = null, ?int $ * ``` * * @param string $command Command to run on localhost. - * @param array|null $options Array of options will override passed named arguments. + * @param string|null $cwd Sets the process working directory. If not set {{working_path}} will be used. * @param int|null $timeout Sets the process timeout (max. runtime). The timeout in seconds (default: 300 sec, `null` to disable). - * @param int|null $idle_timeout Sets the process idle timeout (max. time since last output) in seconds. + * @param int|null $idleTimeout Sets the process idle timeout (max. time since last output) in seconds. * @param string|null $secret Placeholder `%secret%` can be used in command. Placeholder will be replaced with this value and will not appear in any logs. * @param array|null $env Array of environment variables: `runLocally('echo $KEY', env: ['key' => 'value']);` + * @param bool|null $forceOutput Print command output in real-time. + * @param bool|null $nothrow Don't throw an exception of non-zero exit code. * @param string|null $shell Shell to run in. Default is `bash -s`. * + * @return string * @throws RunException - */ -function runLocally(string $command, ?array $options = [], ?int $timeout = null, ?int $idle_timeout = null, ?string $secret = null, ?array $env = null, ?string $shell = null): string -{ - $namedArguments = []; - foreach (['timeout', 'idle_timeout', 'secret', 'env', 'shell'] as $arg) { - if ($$arg !== null) { - $namedArguments[$arg] = $$arg; - } - } - $options = array_merge($namedArguments, $options); + * @throws TimeoutException + */ +function runLocally( + string $command, + ?string $cwd = null, + ?int $timeout = null, + ?int $idleTimeout = null, + ?string $secret = null, + ?array $env = null, + ?bool $forceOutput = false, + ?bool $nothrow = false, + ?string $shell = null, +): string { + $runParams = new RunParams( + shell: $shell ?? 'bash -s', + cwd: $cwd, + env: $env, + nothrow: $nothrow, + timeout: $timeout, + idleTimeout: $idleTimeout, + forceOutput: $forceOutput, + secrets: empty($secret) ? null : ['secret' => $secret], + ); $process = Deployer::get()->processRunner; $command = parse($command); - $env = array_merge_alternate(get('env', []), $options['env'] ?? []); - if (!empty($env)) { - $env = env_stringify($env); - $command = "export $env; $command"; - } - - $output = $process->run(new Localhost(), $command, $options); - + $output = $process->run(new Localhost(), $command, $runParams); return rtrim($output); } diff --git a/tests/legacy/recipe/env.php b/tests/legacy/recipe/env.php index 09da6f9c7..b38329845 100644 --- a/tests/legacy/recipe/env.php +++ b/tests/legacy/recipe/env.php @@ -10,9 +10,9 @@ task('test', function () { info('global=' . run('echo $VAR')); - info('local=' . run('echo $VAR', ['env' => ['VAR' => 'local']])); + info('local=' . run('echo $VAR', env: ['VAR' => 'local'])); info('dotenv=' . run('echo $KEY')); - info('dotenv=' . run('echo $KEY', ['env' => ['KEY' => 'local']])); + info('dotenv=' . run('echo $KEY', env: ['KEY' => 'local'])); }); before('test', function () { diff --git a/tests/legacy/recipe/named_arguments.php b/tests/legacy/recipe/named_arguments.php deleted file mode 100644 index 204412185..000000000 --- a/tests/legacy/recipe/named_arguments.php +++ /dev/null @@ -1,22 +0,0 @@ - 'world']); -}); - -task('options', function () { - run('echo "Hello, $name!"', ['env' => ['name' => 'Anton']]); -}); - -task('options_with_named_arguments', function () { - // The `options:` arg has higher priority than named arguments. - run('echo "Hello, $name!"', ['env' => ['name' => 'override']], env: ['name' => 'world']); -}); - -task('run_locally_named_arguments', function () { - runLocally('echo "Hello, $name!"', env: ['name' => 'world']); -}); diff --git a/tests/src/FunctionsTest.php b/tests/src/FunctionsTest.php index 262e7a782..eaca6fb01 100644 --- a/tests/src/FunctionsTest.php +++ b/tests/src/FunctionsTest.php @@ -111,21 +111,6 @@ public function testRunLocally() self::assertEquals('hello', $output); } - public function testRunLocallyWithOptions() - { - Context::get()->getConfig()->set('env', ['DEPLOYER_ENV' => 'default', 'DEPLOYER_ENV_TMP' => 'default']); - - $output = runLocally('echo $DEPLOYER_ENV'); - self::assertEquals('default', $output); - $output = runLocally('echo $DEPLOYER_ENV_TMP'); - self::assertEquals('default', $output); - - $output = runLocally('echo $DEPLOYER_ENV', ['env' => ['DEPLOYER_ENV_TMP' => 'overwritten']]); - self::assertEquals('default', $output); - $output = runLocally('echo $DEPLOYER_ENV_TMP', ['env' => ['DEPLOYER_ENV_TMP' => 'overwritten']]); - self::assertEquals('overwritten', $output); - } - public function testWithinSetsWorkingPaths() { Context::get()->getConfig()->set('working_path', '/foo');