<?php declare(strict_types=1);
/* (c) Anton Medvedev <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Deployer;
use Deployer\Exception\Exception;
use Deployer\Exception\GracefulShutdownException;
use Deployer\Exception\RunException;
use Deployer\Exception\TimeoutException;
use Deployer\Exception\WillAskUser;
use Deployer\Host\Host;
use Deployer\Host\Localhost;
use Deployer\Host\Range;
use Deployer\Importer\Importer;
use Deployer\Support\ObjectProxy;
use Deployer\Task\Context;
use Deployer\Task\GroupTask;
use Deployer\Task\Task;
use Deployer\Utility\Httpie;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Console\Question\ConfirmationQuestion;
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;
use function Deployer\Support\str_contains;
/**
* Defines a host or hosts.
* ```php
* host('example.org');
* host('prod.example.org', 'staging.example.org');
* ```
*
* Inside task can be used to get `Host` instance of an alias.
* ```php
* task('test', function () {
* $port = host('example.org')->get('port');
* });
* ```
*
* @return Host|ObjectProxy
*/
function host(string ...$hostname)
{
$deployer = Deployer::get();
if (count($hostname) === 1 && $deployer->hosts->has($hostname[0])) {
return $deployer->hosts->get($hostname[0]);
}
$aliases = Range::expand($hostname);
foreach ($aliases as $alias) {
if ($deployer->hosts->has($alias)) {
$host = $deployer->hosts->get($alias);
throw new \InvalidArgumentException("Host \"$host\" already exists.");
}
}
if (count($aliases) === 1) {
$host = new Host($aliases[0]);
$deployer->hosts->set($aliases[0], $host);
return $host;
} else {
$hosts = array_map(function ($hostname) use ($deployer): Host {
$host = new Host($hostname);
$deployer->hosts->set($hostname, $host);
return $host;
}, $aliases);
return new ObjectProxy($hosts);
}
}
/**
* @return Localhost|ObjectProxy
*/
function localhost(string ...$hostnames)
{
$deployer = Deployer::get();
$hostnames = Range::expand($hostnames);
if (count($hostnames) <= 1) {
$host = count($hostnames) === 1 ? new Localhost($hostnames[0]) : new Localhost();
$deployer->hosts->set($host->getAlias(), $host);
return $host;
} else {
$hosts = array_map(function ($hostname) use ($deployer): Localhost {
$host = new Localhost($hostname);
$deployer->hosts->set($host->getAlias(), $host);
return $host;
}, $hostnames);
return new ObjectProxy($hosts);
}
}
/**
* Returns current host.
*/
function currentHost(): Host
{
return Context::get()->getHost();
}
/**
* Returns hosts based on provided selector.
*
* ```php
* on(select('stage=prod, role=db'), function (Host $host) {
* ...
* });
* ```
*
* @return Host[]
*/
function select(string $selector): array
{
return Deployer::get()->selector->select($selector);
}
/**
* Returns array of hosts selected by user via CLI.
*
* @return Host[]
*/
function selectedHosts(): array
{
$hosts = [];
foreach (get('selected_hosts', []) as $alias) {
$hosts[] = Deployer::get()->hosts->get($alias);
}
return $hosts;
}
/**
* Import other php or yaml recipes.
*
* ```php
* import('recipe/common.php');
* ```
*
* ```php
* import(__DIR__ . '/config/hosts.yaml');
* ```
*
* @throws Exception
*/
function import(string $file): void
{
Importer::import($file);
}
/**
* Set task description.
*/
function desc(?string $title = null): ?string
{
static $store = null;
if ($title === null) {
return $store;
} else {
return $store = $title;
}
}
/**
* Define a new task and save to tasks list.
*
* 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
*/
function task(string $name, $body = null): Task
{
$deployer = Deployer::get();
if (empty($body)) {
return $deployer->tasks->get($name);
}
if (is_callable($body)) {
$task = new Task($name, $body);
} elseif (is_array($body)) {
$task = new GroupTask($name, $body);
} else {
throw new \InvalidArgumentException('Task body should be a function or an array.');
}
if ($deployer->tasks->has($name)) {
// If task already exists, try to replace.
$existingTask = $deployer->tasks->get($name);
if (get_class($existingTask) !== get_class($task)) {
// There is no "up" or "down"casting in PHP.
throw new \Exception('Tried to replace Task \'' . $name . '\' with a GroupTask or vice-versa. This is not supported. If you are sure you want to do that, remove the old task `Deployer::get()->tasks->remove(<taskname>)` and then re-add the task.');
}
if ($existingTask instanceof GroupTask) {
$existingTask->setGroup($body);
} elseif ($existingTask instanceof Task) {
$existingTask->setCallback($body);
}
$task = $existingTask;
} else {
// If task does not exist, add it to the Collection.
$deployer->tasks->set($name, $task);
}
$task->saveSourceLocation();
if (!empty(desc())) {
$task->desc(desc());
desc(''); // Clear title.
}
return $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.
*
* @return Task|null
*/
function before(string $task, $do)
{
if (is_closure($do)) {
$newTask = task("before:$task", $do);
before($task, "before:$task");
return $newTask;
}
task($task)->addBefore($do);
return null;
}
/**
* 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.
*
* @return Task|null
*/
function after(string $task, $do)
{
if (is_closure($do)) {
$newTask = task("after:$task", $do);
after($task, "after:$task");
return $newTask;
}
task($task)->addAfter($do);
return null;
}
/**
* Setup which task run on failure of $task.
* 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.
*
* @return Task|null
*/
function fail(string $task, $do)
{
if (is_callable($do)) {
$newTask = task("fail:$task", $do);
fail($task, "fail:$task");
return $newTask;
}
$deployer = Deployer::get();
$deployer->fail->set($task, $do);
return null;
}
/**
* Add users options.
*
* @param string $name The option name
* @param string|array|null $shortcut The shortcuts, can be null, a string of shortcuts delimited by | or an array of shortcuts
* @param int|null $mode The option mode: One of the VALUE_* constants
* @param string $description A description text
* @param string|string[]|int|bool|null $default The default value (must be null for self::VALUE_NONE)
*/
function option(string $name, $shortcut = null, ?int $mode = null, string $description = '', $default = null): void
{
Deployer::get()->inputDefinition->addOption(
new InputOption($name, $shortcut, $mode, $description, $default)
);
}
/**
* Change the current working directory.
*/
function cd(string $path): void
{
set('working_path', parse($path));
}
/**
* 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
*/
function within(string $path, callable $callback)
{
$lastWorkingPath = get('working_path', '');
try {
set('working_path', parse($path));
return $callback();
} finally {
set('working_path', $lastWorkingPath);
}
return null;
}
/**
* Executes given command on remote host.
*
* Examples:
*
* ```php
* run('echo hello world');
* run('cd {{deploy_path}} && git status');
* run('password %secret%', secret: getenv('CI_SECRET'));
* run('curl medv.io', timeout: 5);
* ```
*
* ```php
* $path = run('readlink {{deploy_path}}/current');
* run("echo $path");
* ```
*
* @param string $command Command to run on remote host.
* @param array|null $options Array of options will override passed named arguments.
* @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 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;
}
}
$options = array_merge($namedArguments, $options);
$run = function ($command, $options = []): string {
$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);
} else {
$client = Deployer::get()->sshClient;
$output = $client->run($host, $command, $options);
}
return rtrim($output);
};
if (preg_match('/^sudo\b/', $command)) {
try {
return $run($command, $options);
} catch (RunException $exception) {
$askpass = get('sudo_askpass', '/tmp/dep_sudo_pass');
$password = get('sudo_pass', false);
if ($password === false) {
writeln("<fg=green;options=bold>run</> $command");
$password = askHiddenResponse(" [sudo] password for {{remote_user}}: ");
}
$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)]));
$run("rm $askpass");
return $output;
}
} else {
return $run($command, $options);
}
}
/**
* Execute commands on a local machine.
*
* Examples:
*
* ```php
* $user = runLocally('git config user.name');
* runLocally("echo $user");
* ```
*
* @param string $command Command to run on localhost.
* @param array|null $options Array of options will override passed named arguments.
* @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 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 string|null $shell Shell to run in. Default is `bash -s`.
*
* @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);
$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);
return rtrim($output);
}
/**
* Run test command.
* Example:
*
* ```php
* if (test('[ -d {{release_path}} ]')) {
* ...
* }
* ```
*
*/
function test(string $command): bool
{
$true = '+' . array_rand(array_flip(['accurate', 'appropriate', 'correct', 'legitimate', 'precise', 'right', 'true', 'yes', 'indeed']));
return run("if $command; then echo $true; fi") === $true;
}
/**
* Run test command locally.
* Example:
*
* testLocally('[ -d {{local_release_path}} ]')
*
*/
function testLocally(string $command): bool
{
return runLocally("if $command; then echo +true; fi") === '+true';
}
/**
* Iterate other hosts, allowing to call run a func in callback.
*
* ```php
* on(select('stage=prod, role=db'), function ($host) {
* ...
* });
* ```
*
* ```php
* on(host('example.org'), function ($host) {
* ...
* });
* ```
*
* ```php
* on(Deployer::get()->hosts, function ($host) {
* ...
* });
* ```
*
* @param Host|Host[] $hosts
*/
function on($hosts, callable $callback): void
{
if (!is_array($hosts) && !($hosts instanceof \Traversable)) {
$hosts = [$hosts];
}
foreach ($hosts as $host) {
if ($host instanceof Host) {
$host->config()->load();
Context::push(new Context($host));
try {
$callback($host);
$host->config()->save();
} catch (GracefulShutdownException $e) {
Deployer::get()->messenger->renderException($e, $host);
} finally {
Context::pop();
}
} else {
throw new \InvalidArgumentException("Function on can iterate only on Host instances.");
}
}
}
/**
* Runs a task.
* ```php
* invoke('deploy:symlink');
* ```
*
* @throws Exception
*/
function invoke(string $taskName): void
{
$task = Deployer::get()->tasks->get($taskName);
Deployer::get()->messenger->startTask($task);
$task->run(Context::get());
Deployer::get()->messenger->endTask($task);
}
/**
* Upload file or directory to host.
*
* > You may have noticed that there is a trailing slash (/) at the end of the first argument in the above command, this is necessary to mean “the contents of build“.
* >
* > The alternative, without the trailing slash, would place build, including the directory, within public. This would create a hierarchy that looks like: {{release_path}}/public/build
*
* The `$config` array supports the following keys:
*
* - `flags` for overriding the default `-azP` passed to the `rsync` command
* - `options` with additional flags passed directly to the `rsync` command
* - `timeout` for `Process::fromShellCommandline()` (`null` by default)
* - `progress_bar` to display upload/download progress
* - `display_stats` to display rsync set of statistics
*
* @param string|string[] $source
* @param array $config
* @phpstan-param array{flags?: string, options?: array, timeout?: int|null, progress_bar?: bool, display_stats?: bool} $config
*
* @throws RunException
*/
function upload($source, string $destination, array $config = []): void
{
$rsync = Deployer::get()->rsync;
$host = currentHost();
$source = is_array($source) ? array_map('Deployer\parse', $source) : parse($source);
$destination = parse($destination);
if ($host instanceof Localhost) {
$rsync->call($host, $source, $destination, $config);
} else {
$rsync->call($host, $source, "{$host->connectionString()}:$destination", $config);
}
}
/**
* Download file or directory from host
*
* @param array $config
*
* @throws RunException
*/
function download(string $source, string $destination, array $config = []): void
{
$rsync = Deployer::get()->rsync;
$host = currentHost();
$source = parse($source);
$destination = parse($destination);
if ($host instanceof Localhost) {
$rsync->call($host, $source, $destination, $config);
} else {
$rsync->call($host, "{$host->connectionString()}:$source", $destination, $config);
}
}
/**
* Writes an info message.
*/
function info(string $message): void
{
writeln("<fg=green;options=bold>info</> " . parse($message));
}
/**
* Writes an warning message.
*/
function warning(string $message): void
{
$message = "<fg=yellow;options=bold>warning</> <comment>$message</comment>";
if (Context::has()) {
writeln($message);
} else {
Deployer::get()->output->writeln($message);
}
}
/**
* Writes a message to the output and adds a newline at the end.
*/
function writeln(string $message, int $options = 0): void
{
$host = currentHost();
output()->writeln("[$host] " . parse($message), $options);
}
/**
* Parse set values.
*/
function parse(string $value): string
{
return Context::get()->getConfig()->parse($value);
}
/**
* Setup configuration option.
* @param mixed $value
* @throws Exception
*/
function set(string $name, $value): void
{
if (!Context::has()) {
Deployer::get()->config->set($name, $value);
} else {
Context::get()->getConfig()->set($name, $value);
}
}
/**
* Merge new config params to existing config array.
*
* @param array $array
*/
function add(string $name, array $array): void
{
if (!Context::has()) {
Deployer::get()->config->add($name, $array);
} else {
Context::get()->getConfig()->add($name, $array);
}
}
/**
* Get configuration value.
*
* @param mixed|null $default
*
* @return mixed
*/
function get(string $name, $default = null)
{
if (!Context::has()) {
return Deployer::get()->config->get($name, $default);
} else {
return Context::get()->getConfig()->get($name, $default);
}
}
/**
* Check if there is such configuration option.
*/
function has(string $name): bool
{
if (!Context::has()) {
return Deployer::get()->config->has($name);
} else {
return Context::get()->getConfig()->has($name);
}
}
function ask(string $message, ?string $default = null, ?array $autocomplete = null): ?string
{
if (defined('DEPLOYER_NO_ASK')) {
throw new WillAskUser($message);
}
Context::required(__FUNCTION__);
if (output()->isQuiet()) {
return $default;
}
if (Deployer::isWorker()) {
return Deployer::proxyCallToMaster(currentHost(), __FUNCTION__, ...func_get_args());
}
/** @var QuestionHelper */
$helper = Deployer::get()->getHelper('question');
$tag = currentHost()->getTag();
$message = parse($message);
$message = "[$tag] <question>$message</question> " . (($default === null) ? "" : "(default: $default) ");
$question = new Question($message, $default);
if (!empty($autocomplete)) {
$question->setAutocompleterValues($autocomplete);
}
return $helper->ask(input(), output(), $question);
}
/**
* @param mixed $default
* @return mixed
* @throws Exception
*/
function askChoice(string $message, array $availableChoices, $default = null, bool $multiselect = false)
{
if (defined('DEPLOYER_NO_ASK')) {
throw new WillAskUser($message);
}
Context::required(__FUNCTION__);
if (empty($availableChoices)) {
throw new \InvalidArgumentException('Available choices should not be empty');
}
if ($default !== null && !array_key_exists($default, $availableChoices)) {
throw new \InvalidArgumentException('Default choice is not available');
}
if (output()->isQuiet()) {
if ($default === null) {
$default = key($availableChoices);
}
return [$default => $availableChoices[$default]];
}
if (Deployer::isWorker()) {
return Deployer::proxyCallToMaster(currentHost(), __FUNCTION__, ...func_get_args());
}
/** @var QuestionHelper */
$helper = Deployer::get()->getHelper('question');
$tag = currentHost()->getTag();
$message = parse($message);
$message = "[$tag] <question>$message</question> " . (($default === null) ? "" : "(default: $default) ");
$question = new ChoiceQuestion($message, $availableChoices, $default);
$question->setMultiselect($multiselect);
return $helper->ask(input(), output(), $question);
}
function askConfirmation(string $message, bool $default = false): bool
{
if (defined('DEPLOYER_NO_ASK')) {
throw new WillAskUser($message);
}
Context::required(__FUNCTION__);
if (output()->isQuiet()) {
return $default;
}
if (Deployer::isWorker()) {
return Deployer::proxyCallToMaster(currentHost(), __FUNCTION__, ...func_get_args());
}
/** @var QuestionHelper */
$helper = Deployer::get()->getHelper('question');
$yesOrNo = $default ? 'Y/n' : 'y/N';
$tag = currentHost()->getTag();
$message = parse($message);
$message = "[$tag] <question>$message</question> [$yesOrNo] ";
$question = new ConfirmationQuestion($message, $default);
return $helper->ask(input(), output(), $question);
}
function askHiddenResponse(string $message): string
{
if (defined('DEPLOYER_NO_ASK')) {
throw new WillAskUser($message);
}
Context::required(__FUNCTION__);
if (output()->isQuiet()) {
return '';
}
if (Deployer::isWorker()) {
return (string)Deployer::proxyCallToMaster(currentHost(), __FUNCTION__, ...func_get_args());
}
/** @var QuestionHelper */
$helper = Deployer::get()->getHelper('question');
$tag = currentHost()->getTag();
$message = parse($message);
$message = "[$tag] <question>$message</question> ";
$question = new Question($message);
$question->setHidden(true);
$question->setHiddenFallback(false);
return (string)$helper->ask(input(), output(), $question);
}
function input(): InputInterface
{
return Deployer::get()->input;
}
function output(): OutputInterface
{
return Deployer::get()->output;
}
/**
* Check if command exists
*
* @throws RunException
*/
function commandExist(string $command): bool
{
return test("hash $command 2>/dev/null");
}
/**
* @throws RunException
*/
function commandSupportsOption(string $command, string $option): bool
{
$man = run("(man $command 2>&1 || $command -h 2>&1 || $command --help 2>&1) | grep -- $option || true");
if (empty($man)) {
return false;
}
return str_contains($man, $option);
}
/**
* @throws RunException
*/
function which(string $name): string
{
$nameEscaped = escapeshellarg($name);
// Try `command`, should cover all Bourne-like shells
// Try `which`, should cover most other cases
// Fallback to `type` command, if the rest fails
$path = run("command -v $nameEscaped || which $nameEscaped || type -p $nameEscaped");
if (empty($path)) {
throw new \RuntimeException("Can't locate [$nameEscaped] - neither of [command|which|type] commands are available");
}
// Deal with issue when `type -p` outputs something like `type -ap` in some implementations
return trim(str_replace("$name is", "", $path));
}
/**
* Returns remote environments variables as an array.
* ```php
* $remotePath = remoteEnv()['PATH'];
* run('echo $PATH', env: ['PATH' => "/home/user/bin:$remotePath"]);
* ```
*/
function remoteEnv(): array
{
$vars = [];
$data = run('env');
foreach (explode("\n", $data) as $line) {
list($name, $value) = explode('=', $line, 2);
$vars[$name] = $value;
}
return $vars;
}
/**
* Creates a new exception.
*/
function error(string $message): Exception
{
return new Exception(parse($message));
}
/**
* Returns current timestamp in UTC timezone in ISO8601 format.
*/
function timestamp(): string
{
return (new \DateTime('now', new \DateTimeZone('UTC')))->format(\DateTime::ISO8601);
}
/**
* Example usage:
* ```php
* $result = fetch('{{domain}}', info: $info);
* var_dump($info['http_code'], $result);
* ```
*/
function fetch(string $url, string $method = 'get', array $headers = [], ?string $body = null, ?array &$info = null, bool $nothrow = false): string
{
$url = parse($url);
if (strtolower($method) === 'get') {
$http = Httpie::get($url);
} elseif (strtolower($method) === 'post') {
$http = Httpie::post($url);
} else {
throw new \InvalidArgumentException("Unknown method \"$method\".");
}
$http = $http->nothrow($nothrow);
foreach ($headers as $key => $value) {
$http = $http->header($key, $value);
}
if ($body !== null) {
$http = $http->body($body);
}
return $http->send($info);
}