deployphp/deployer

View on GitHub
src/Deployer.php

Summary

Maintainability
D
2 days
Test Coverage
<?php

declare(strict_types=1);

/* (c) Anton Medvedev <anton@medv.io>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Deployer;

use Deployer\Collection\Collection;
use Deployer\Command\BlackjackCommand;
use Deployer\Command\ConfigCommand;
use Deployer\Command\InitCommand;
use Deployer\Command\MainCommand;
use Deployer\Command\RunCommand;
use Deployer\Command\SshCommand;
use Deployer\Command\TreeCommand;
use Deployer\Command\WorkerCommand;
use Deployer\Component\PharUpdate\Console\Command as PharUpdateCommand;
use Deployer\Component\PharUpdate\Console\Helper as PharUpdateHelper;
use Deployer\Component\Pimple\Container;
use Deployer\ProcessRunner\Printer;
use Deployer\ProcessRunner\ProcessRunner;
use Deployer\Ssh\SshClient;
use Deployer\Configuration;
use Deployer\Executor\Master;
use Deployer\Executor\Messenger;
use Deployer\Host\Host;
use Deployer\Host\HostCollection;
use Deployer\Host\Localhost;
use Deployer\Importer\Importer;
use Deployer\Logger\Handler\FileHandler;
use Deployer\Logger\Handler\NullHandler;
use Deployer\Logger\Logger;
use Deployer\Selector\Selector;
use Deployer\Task\ScriptManager;
use Deployer\Task\TaskCollection;
use Deployer\Utility\Httpie;
use Deployer\Utility\Rsync;
use Symfony\Component\Console;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Input\InputDefinition;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;

/**
 * @property Application $console
 * @property InputInterface $input
 * @property OutputInterface $output
 * @property Task\TaskCollection|Task\Task[] $tasks
 * @property HostCollection|Host[] $hosts
 * @property Configuration $config
 * @property Rsync $rsync
 * @property SshClient $sshClient
 * @property ProcessRunner $processRunner
 * @property Task\ScriptManager $scriptManager
 * @property Selector $selector
 * @property Master $master
 * @property Messenger $messenger
 * @property Messenger $logger
 * @property Printer $pop
 * @property Collection $fail
 * @property InputDefinition $inputDefinition
 * @property Importer $importer
 */
class Deployer extends Container
{
    private static Deployer $instance;

    public function __construct(Application $console)
    {
        parent::__construct();

        /******************************
         *           Console          *
         ******************************/

        $console->getDefinition()->addOption(
            new InputOption('file', 'f', InputOption::VALUE_REQUIRED, 'Recipe file path'),
        );

        $this['console'] = function () use ($console) {
            return $console;
        };
        $this['input'] = function () {
            throw new \RuntimeException('Uninitialized "input" in Deployer container.');
        };
        $this['output'] = function () {
            throw new \RuntimeException('Uninitialized "output" in Deployer container.');
        };
        $this['inputDefinition'] = function () {
            return new InputDefinition();
        };
        $this['questionHelper'] = function () {
            return $this->getHelper('question');
        };

        /******************************
         *           Config           *
         ******************************/

        $this['config'] = function () {
            return new Configuration();
        };
        // -l  act as if it had been invoked as a login shell (i.e. source ~/.profile file)
        // -s  commands are read from the standard input (no arguments should remain after this option)
        $this->config['shell'] = function () {
            if (currentHost() instanceof Localhost) {
                return 'bash -s'; // Non-login shell for localhost.
            }
            return 'bash -ls';
        };
        $this->config['forward_agent'] = true;
        $this->config['ssh_multiplexing'] = true;

        /******************************
         *            Core            *
         ******************************/

        $this['pop'] = function ($c) {
            return new Printer($c['output']);
        };
        $this['sshClient'] = function ($c) {
            return new SshClient($c['output'], $c['pop'], $c['logger']);
        };
        $this['rsync'] = function ($c) {
            return new Rsync($c['pop'], $c['output']);
        };
        $this['processRunner'] = function ($c) {
            return new ProcessRunner($c['pop'], $c['logger']);
        };
        $this['tasks'] = function () {
            return new TaskCollection();
        };
        $this['hosts'] = function () {
            return new HostCollection();
        };
        $this['scriptManager'] = function ($c) {
            return new ScriptManager($c['tasks']);
        };
        $this['selector'] = function ($c) {
            return new Selector($c['hosts']);
        };
        $this['fail'] = function () {
            return new Collection();
        };
        $this['messenger'] = function ($c) {
            return new Messenger($c['input'], $c['output'], $c['logger']);
        };
        $this['master'] = function ($c) {
            return new Master(
                $c['hosts'],
                $c['input'],
                $c['output'],
                $c['messenger'],
            );
        };
        $this['importer'] = function () {
            return new Importer();
        };

        /******************************
         *           Logger           *
         ******************************/

        $this['log_handler'] = function () {
            return !empty($this['log'])
                ? new FileHandler($this['log'])
                : new NullHandler();
        };
        $this['logger'] = function () {
            return new Logger($this['log_handler']);
        };

        self::$instance = $this;
    }

    public static function get(): self
    {
        return self::$instance;
    }

    public function init(): void
    {
        $this->addTaskCommands();
        $this->getConsole()->add(new BlackjackCommand());
        $this->getConsole()->add(new ConfigCommand($this));
        $this->getConsole()->add(new WorkerCommand($this));
        $this->getConsole()->add(new InitCommand());
        $this->getConsole()->add(new TreeCommand($this));
        $this->getConsole()->add(new SshCommand($this));
        $this->getConsole()->add(new RunCommand($this));
        if (self::isPharArchive()) {
            $selfUpdate = new PharUpdateCommand('self-update');
            $selfUpdate->setDescription('Updates deployer.phar to the latest version');
            $selfUpdate->setManifestUri('https://deployer.org/manifest.json');
            $selfUpdate->setRunningFile(DEPLOYER_BIN);
            $this->getConsole()->add($selfUpdate);
            $this->getConsole()->getHelperSet()->set(new PharUpdateHelper());
        }
    }

    /**
     * Transform tasks to console commands.
     */
    public function addTaskCommands(): void
    {
        foreach ($this->tasks as $name => $task) {
            $command = new MainCommand($name, $task->getDescription(), $this);
            $command->setHidden($task->isHidden());

            $this->getConsole()->add($command);
        }
    }

    public function __get(string $name): mixed
    {
        if (isset($this[$name])) {
            return $this[$name];
        } else {
            throw new \InvalidArgumentException("Property \"$name\" does not exist.");
        }
    }

    public function __set(string $name, mixed $value): void
    {
        $this[$name] = $value;
    }

    public function getConsole(): Application
    {
        return $this['console'];
    }

    public function getHelper(string $name): Console\Helper\HelperInterface
    {
        return $this->getConsole()->getHelperSet()->get($name);
    }

    public static function run(string $version, ?string $deployFile): void
    {
        if (str_contains($version, 'master')) {
            // Get version from composer.lock
            $lockFile = __DIR__ . '/../../../../composer.lock';
            if (file_exists($lockFile)) {
                $content = file_get_contents($lockFile);
                $json = json_decode($content);
                foreach ($json->packages as $package) {
                    if ($package->name === 'deployer/deployer') {
                        $version = $package->version;
                    }
                }
            }
        }

        // Version must be without "v" prefix.
        //    Incorrect: v7.0.0
        //    Correct: 7.0.0
        // But deployphp/deployer uses tags with "v", and it gets passed to
        // the composer.json file. Let's manually remove it from the version.
        if (preg_match("/^v/", $version)) {
            $version = substr($version, 1);
        }

        if (!defined('DEPLOYER_VERSION')) {
            define('DEPLOYER_VERSION', $version);
        }

        $input = new ArgvInput();
        $output = new ConsoleOutput();

        try {
            $console = new Application('Deployer', $version);
            $deployer = new self($console);

            // Import recipe file
            if (is_readable($deployFile ?? '')) {
                $deployer->importer->import($deployFile);
            }

            $deployer->init();
            $console->run($input, $output);

        } catch (Throwable $exception) {
            if (str_contains("$input", "-vvv")) {
                $output->setVerbosity(OutputInterface::VERBOSITY_DEBUG);
            }
            self::printException($output, $exception);

            exit(1);
        }
    }

    public static function printException(OutputInterface $output, Throwable $exception): void
    {
        $class = get_class($exception);
        $file = basename($exception->getFile());
        $output->writeln([
            "<fg=white;bg=red> {$class} </> <comment>in {$file} on line {$exception->getLine()}:</>",
            "",
            implode("\n", array_map(function ($line) {
                return "  " . $line;
            }, explode("\n", $exception->getMessage()))),
            "",
        ]);
        if ($output->isDebug()) {
            $output->writeln($exception->getTraceAsString());
        }

        if ($exception->getPrevious()) {
            self::printException($output, $exception->getPrevious());
        }
    }

    public static function isWorker(): bool
    {
        return defined('MASTER_ENDPOINT');
    }

    /**
     * @return array|bool|string
     */
    public static function masterCall(Host $host, string $func, mixed ...$arguments): mixed
    {
        // As request to master will stop master permanently, wait a little bit
        // in order for ticker gather worker outputs and print it to user.
        usleep(100_000); // Sleep 100ms.

        return Httpie::get(MASTER_ENDPOINT . '/proxy')
            ->setopt(CURLOPT_CONNECTTIMEOUT, 0) // no timeout
            ->setopt(CURLOPT_TIMEOUT, 0) // no timeout
            ->jsonBody([
                'host' => $host->getAlias(),
                'func' => $func,
                'arguments' => $arguments,
            ])
            ->getJson();
    }

    public static function isPharArchive(): bool
    {
        return str_starts_with(__FILE__, 'phar:');
    }
}