This commit is contained in:
Stefan Wieczorek 2023-05-30 11:57:37 +02:00
commit bbb491d3d0
46 changed files with 4660 additions and 0 deletions

5
.env Executable file
View File

@ -0,0 +1,5 @@
APP_ENV=dev
APP_VERSION='1.0.0'
APP_SECRET=d7e71b25a69f640224cfb1df1328454f
APP_DEV_GIT_REPOSITORY='https://github.com/swieczorek/tmp-delete-me.git'
APP_DEV_DEPLOY_DIRECTORY='/home/moby/htdocs/www.moby.io/'

9
.gitignore vendored Executable file
View File

@ -0,0 +1,9 @@
/.env.local
/.env.local.php
/.env.*.local
/bin/compiled/
/data/keys/private.key
/config/secrets/prod/prod.decrypt.private.php
/public/bundles/
/var/
/vendor/

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright © 2013—2023 Anton Medvedev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

17
bin/console Executable file
View File

@ -0,0 +1,17 @@
#!/usr/bin/env php8.2
<?php
use App\Kernel;
use Symfony\Bundle\FrameworkBundle\Console\Application;
if (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {
throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
}
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
$kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
return new Application($kernel);
};

26
bin/create-phar Executable file
View File

@ -0,0 +1,26 @@
#!/usr/bin/env php8.2
<?php
$cwd = getcwd();
assert(is_string($cwd));
require __DIR__.'/../src/Compiler/bootstrap.php';
use Symfony\Component\Dotenv\Dotenv;
use App\Compiler\Compiler;
$dotenv = new Dotenv();
$dotenv->load(__DIR__.'/../.env');
error_reporting(-1);
ini_set('display_errors', '1');
try {
$privateKey = realpath(__DIR__.'/../data/keys/private.key');
$compiler = new Compiler();
$compiler->setPrivateKey($privateKey);
$compiler->compile($_ENV['APP_VERSION']);
} catch (\Exception $e) {
echo 'Failed to compile phar: ['.get_class($e).'] '.$e->getMessage().' at '.$e->getFile().':'.$e->getLine().PHP_EOL;
exit(1);
}

46
bin/dploy Executable file
View File

@ -0,0 +1,46 @@
#!/usr/bin/env php8.2
<?php
use App\Kernel;
use App\Console\Application;
set_time_limit(0);
if (false === defined('IS_PHAR')) {
define('IS_PHAR', (strlen(Phar::running()) > 0));
}
if (function_exists('ini_set')) {
@ini_set('display_errors', '1');
if ($memoryLimit = getenv('DPLOY_MEMORY_LIMIT')) {
@ini_set('memory_limit', $memoryLimit);
} else {
$memoryInBytes = function ($value) {
$unit = strtolower(substr($value, -1, 1));
$value = (int) $value;
switch($unit) {
case 'g':
$value *= 1024;
case 'm':
$value *= 1024;
case 'k':
$value *= 1024;
}
return $value;
};
$memoryLimit = trim(ini_get('memory_limit'));
if ($memoryLimit != -1 && $memoryInBytes($memoryLimit) < 1024 * 1024 * 1536) {
@ini_set('memory_limit', '1536M');
}
unset($memoryInBytes);
}
unset($memoryLimit);
}
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
$kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
return new Application($kernel);
};

2
changelog Executable file
View File

@ -0,0 +1,2 @@
1.0.0 Stefan Wieczorek <stefan.wieczorek@cloudpanel.io> Thu, 01 Jun 2023 08:08:08
- Initial Release

69
composer.json Executable file
View File

@ -0,0 +1,69 @@
{
"type": "project",
"license": "proprietary",
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=8.1",
"ext-ctype": "*",
"ext-iconv": "*",
"composer/pcre": "^2.1 || ^3.1",
"symfony/console": "6.2.*",
"symfony/dotenv": "6.2.*",
"symfony/filesystem": "6.2.*",
"symfony/flex": "^2",
"symfony/framework-bundle": "6.2.*",
"symfony/http-client": "6.2.*",
"symfony/process": "6.2.*",
"symfony/runtime": "6.2.*",
"symfony/yaml": "6.2.*"
},
"config": {
"allow-plugins": {
"php-http/discovery": true,
"symfony/flex": true,
"symfony/runtime": true
},
"sort-packages": true
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
},
"replace": {
"symfony/polyfill-ctype": "*",
"symfony/polyfill-iconv": "*",
"symfony/polyfill-php72": "*",
"symfony/polyfill-php73": "*",
"symfony/polyfill-php74": "*",
"symfony/polyfill-php80": "*",
"symfony/polyfill-php81": "*"
},
"scripts": {
"auto-scripts": {
"cache:clear": "symfony-cmd",
"assets:install %PUBLIC_DIR%": "symfony-cmd"
},
"post-install-cmd": [
"@auto-scripts"
],
"post-update-cmd": [
"@auto-scripts"
]
},
"conflict": {
"symfony/symfony": "*"
},
"extra": {
"symfony": {
"allow-contrib": false,
"require": "6.2.*"
}
}
}

2689
composer.lock generated Executable file

File diff suppressed because it is too large Load Diff

5
config/bundles.php Executable file
View File

@ -0,0 +1,5 @@
<?php
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
];

19
config/packages/cache.yaml Executable file
View File

@ -0,0 +1,19 @@
framework:
cache:
# Unique name of your app: used to compute stable namespaces for cache keys.
#prefix_seed: your_vendor_name/app_name
# The "app" cache stores to the filesystem by default.
# The data in this cache should persist between deploys.
# Other options include:
# Redis
#app: cache.adapter.redis
#default_redis_provider: redis://localhost
# APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
#app: cache.adapter.apcu
# Namespaced pools use the above "app" backend by default
#pools:
#my.dedicated.cache: null

29
config/packages/framework.yaml Executable file
View File

@ -0,0 +1,29 @@
# see https://symfony.com/doc/current/reference/configuration/framework.html
framework:
secret: '%env(APP_SECRET)%'
#csrf_protection: true
http_method_override: false
handle_all_throwables: true
# Enables session support. Note that the session will ONLY be started if you read or write from it.
# Remove or comment this section to explicitly disable session support.
session:
handler_id: null
cookie_secure: auto
cookie_samesite: lax
storage_factory_id: session.storage.factory.native
#esi: true
#fragments: true
php_errors:
log: true
http_client:
default_options:
http_version: '2.0'
when@test:
framework:
test: true
session:
storage_factory_id: session.storage.factory.mock_file

12
config/packages/routing.yaml Executable file
View File

@ -0,0 +1,12 @@
framework:
router:
utf8: true
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
#default_uri: http://localhost
when@prod:
framework:
router:
strict_requirements: null

5
config/preload.php Executable file
View File

@ -0,0 +1,5 @@
<?php
if (file_exists(dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php')) {
require dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php';
}

5
config/routes.yaml Executable file
View File

@ -0,0 +1,5 @@
controllers:
resource:
path: ../src/Controller/
namespace: App\Controller
type: attribute

4
config/routes/framework.yaml Executable file
View File

@ -0,0 +1,4 @@
when@dev:
_errors:
resource: '@FrameworkBundle/Resources/config/routing/errors.xml'
prefix: /_error

40
config/services.yaml Executable file
View File

@ -0,0 +1,40 @@
# This file is the entry point to configure your own services.
# Files in the packages/ subdirectory configure your dependencies.
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
services:
# default configuration for services in *this* file
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
# makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name
App\:
resource: '../src/'
exclude:
- '../src/DependencyInjection/'
- '../src/Entity/'
- '../src/Kernel.php'
App\Dploy:
arguments: ['@http_client']
public: true
App\Command\SetupCommand:
public: true
App\Command\DeployCommand:
public: true
App\Command\SelfUpdateCommand:
public: true
App\Command\TestCommand:
public: true
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones

9
data/keys/public.key Executable file
View File

@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAycvbAEuoQ8Qw8VCQgmIL
xccc4P5fKKyrnCXUve9cWbVuXaRgRVC9Mnn3bOy78wXhdQwcptjjTavRngFHO3hM
8zZqnUGotmOid0z/CLxLFNvQ7crUODFgiY8o3xydW3uS8IUGvnLlsqFxTHcBmcQR
H+hT8ONTgTWX3om/dXClSjwvSXXzUl1ps+Be1UQeiYWo9yW+RmWXMy7eZ/Y7ca8k
IObvyGc4bjMtqWH+L9HXPmkoZhl1XQuFJ/m4DdEHlCEbjjwjHC9FeTc1AoXPp47i
wyL736NYBs7dU+E6QXDk+tX82OTHVBOSaj2jcpmoyq5zyqyQ2pJp6cbnf8rma3ah
0wIDAQAB
-----END PUBLIC KEY-----

9
public/index.php Executable file
View File

@ -0,0 +1,9 @@
<?php
use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};

32
src/Command/Command.php Executable file
View File

@ -0,0 +1,32 @@
<?php declare(strict_types=1);
namespace App\Command;
use Symfony\Component\Console\Command\Command as BaseCommand;
abstract class Command extends BaseCommand
{
public const TEMPLATES_GITHUB_REPOSITORY = 'https://github.com/cloudpanel-io/dploy-application-templates';
protected function get(string $id)
{
return $this->getContainer()->get($id);
}
protected function getContainer()
{
return $this->getApplication()->getContainer();
}
protected function getKernel()
{
$container = $this->getContainer();
return $container->get('kernel');
}
protected function getProjectDirectory(): string
{
$kernel = $this->getKernel();
return $kernel->getProjectDir();
}
}

53
src/Command/DeployCommand.php Executable file
View File

@ -0,0 +1,53 @@
<?php declare(strict_types=1);
namespace App\Command;
use App\Deployment\Deployment;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Filesystem\Filesystem;
use App\Deployment\Config as DeploymentConfig;
class DeployCommand extends Command
{
protected function configure(): void
{
$this->setName('deploy');
$this->setHidden(false);
$this->setDescription('Deploys a branch or tag.');
$this->addArgument('version', InputArgument::REQUIRED, 'version');
$this->setHelp(
<<<EOT
<info>Examples:</info>
Deploying a branch: <comment>dploy deploy main</comment>
Deploying a tag: <comment>dploy deploy v1.0.0</comment>
EOT);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
try {
$version = trim($input->getArgument('version'));
$config = new DeploymentConfig();
$systemUserId = $config->getSystemUserId();
if (0 == $systemUserId) {
throw new \Exception('Not allowed to run the command as root, use the site user.');
}
$filesystem = new Filesystem();
$configFile = $config->getConfigFile();
if (true === $filesystem->exists($configFile)) {
$deployment = new Deployment($output, $config, $version);
$deployment->deploy();
$output->writeln('<info>Deployment has been completed!</info>');
} else {
throw new \Exception(sprintf('Config file "%s" does not exist. Did you run dploy setup $template?', $configFile));
}
return Command::SUCCESS;
} catch (\Exception $e) {
$output->writeln(sprintf('<error>%s</error>', $e->getMessage()));
return Command::FAILURE;
}
}
}

107
src/Command/InitCommand.php Executable file
View File

@ -0,0 +1,107 @@
<?php declare(strict_types=1);
namespace App\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Process\Process;
use App\Deployment\Setup as DeploymentSetup;
use App\Deployment\Config as DeploymentConfig;
class InitCommand extends Command
{
protected function configure(): void
{
$this->setName('init');
$this->setDescription('Setups the project directory structure.');
$this->addArgument('application', InputArgument::OPTIONAL, 'Downloads a config for the application.', 'generic');
$this->setHelp(
<<<EOT
The <info>init</info> command downloads a pre-configured <info>config.yml</info> from
<comment>https://github.com/cloudpanel-io/dploy-application-templates</comment>
<info>dploy init laravel</info>
EOT);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
try {
$applicationName = trim($input->getArgument('application'));
$filesystem = new Filesystem();
$config = new DeploymentConfig();
$systemUserId = $config->getSystemUserId();
if (0 == $systemUserId) {
throw new \Exception('Not allowed to run the command as root, use the site user.');
}
$configFile = $config->getConfigFile();
if (false === $filesystem->exists($configFile)) {
$templates = $this->getTemplates();
if (false === isset($templates[$applicationName])) {
throw new \Exception(sprintf('Template %s does not exist, available templates: %s', $applicationName, implode(',', array_keys($templates))));
}
if (true === function_exists('xdebug_is_debugger_active') && true === xdebug_is_debugger_active()) {
$gitRepository = $_ENV['APP_DEV_GIT_REPOSITORY'];
$deployDirectory = $_ENV['APP_DEV_DEPLOY_DIRECTORY'];
} else {
$helper = $this->getHelper('question');
$gitRepositoryQuestion = new Question('Git Repository: ');
$gitRepository = $helper->ask($input, $output, $gitRepositoryQuestion);
$output->writeln(sprintf('<info>Deploy directory like: /home/%s/htdocs/www.domain.com</info>', $config->getSystemUserName()));
$deployDirectoryQuestion = new Question('Deploy Directory: ');
$deployDirectory = $helper->ask($input, $output, $deployDirectoryQuestion);
}
$deployDirectory = rtrim($deployDirectory, '/');
$template = str_replace(['{git_repository}', '{deploy_directory}'], [$gitRepository, $deployDirectory], $templates[$applicationName]);
$tmpFile = tmpfile();
$tmpFilePath = stream_get_meta_data($tmpFile)['uri'];
file_put_contents($tmpFilePath, $template);
$configDirectory = $config->getConfigDirectory();
$filesystem = new Filesystem();
$filesystem->mkdir($configDirectory, 0770);
$filesystem->copy($tmpFilePath, $configFile);
$setup = new DeploymentSetup($config);
$setup->create();
$output->writeln(sprintf('<comment>%s</comment>', sprintf('The config "%s" has been created.', $configFile)));
} else {
throw new \Exception(sprintf('Config file %s already exists, nothing to do.', $configFile));
}
return Command::SUCCESS;
} catch (\Exception $e) {
$output->writeln(sprintf('<error>%s</error>', $e->getMessage()));
return Command::FAILURE;
}
}
private function getTemplates(): array
{
$templates = [];
try {
$filesystem = new Filesystem();
$tmpDirectory = sprintf('%s/%s', rtrim(sys_get_temp_dir(), '/'), uniqid());
$gitCloneCommand = sprintf('/usr/bin/git clone %s %s', self::TEMPLATES_GITHUB_REPOSITORY, $tmpDirectory);
$process = Process::fromShellCommandline($gitCloneCommand);
$process->setTimeout(600);
$process->run();
$directoryIterator = new \DirectoryIterator($tmpDirectory);
foreach ($directoryIterator as $fileInfo) {
$name = $fileInfo->getFilename();
$filePath = $fileInfo->getPathname();
if (false === empty($name) && true === is_file($filePath) && true === file_exists($filePath)) {
$templates[$name] = file_get_contents($filePath);
}
}
} catch (\Exception $e) {
throw $e;
} finally {
if (true === isset($tmpDirectory) && true === is_dir($tmpDirectory)) {
$filesystem->remove($tmpDirectory);
}
}
return $templates;
}
}

94
src/Command/ListCommand.php Executable file
View File

@ -0,0 +1,94 @@
<?php declare(strict_types=1);
namespace App\Command;
use Symfony\Component\Console\Descriptor\ApplicationDescription;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Helper\DescriptorHelper;
use Symfony\Component\Console\Helper\Helper;
use App\Command\Command as BaseCommand;
class ListCommand extends BaseCommand
{
protected OutputInterface $output;
protected function configure()
{
$this
->setName('list')
->setDefinition([
new InputArgument('namespace', InputArgument::OPTIONAL, 'The namespace name', null, function () {
return array_keys((new ApplicationDescription($this->getApplication()))->getNamespaces());
}),
new InputOption('raw', null, InputOption::VALUE_NONE, 'To output raw command list'),
new InputOption('format', null, InputOption::VALUE_REQUIRED, 'The output format (txt, xml, json, or md)', 'txt', function () {
return (new DescriptorHelper())->getFormats();
}),
new InputOption('short', null, InputOption::VALUE_NONE, 'To skip describing commands\' arguments'),
])
->setDescription('List commands')
->setHelp(<<<'EOF'
The <info>%command.name%</info> command lists all commands:
<info>%command.full_name%</info>
You can also display the commands for a specific namespace:
<info>%command.full_name% test</info>
You can also output the information in other formats by using the <comment>--format</comment> option:
<info>%command.full_name% --format=xml</info>
It's also possible to get raw list of commands (useful for embedding command runner):
<info>%command.full_name% --raw</info>
EOF
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$application = $this->getApplication();
if ('' != $help = $application->getHelp()) {
$output->writeln($help.PHP_EOL);
}
$output->writeln('<comment>Usage:</comment>');
$output->writeln(' command [options] [arguments]'.PHP_EOL);
$output->writeln('<comment>Available commands:</comment>');
$commands = $application->getCommands();
$width = $this->getColumnWidth($commands);
foreach ($commands as $command) {
if (false === ($command instanceof BaseCommand) || (true === $command->isHidden())) {
continue;
}
$name = $command->getName();
$spacingWidth = $width - Helper::width($name);
$output->writeln(sprintf(' <info>%s</info>%s %s', $name, str_repeat(' ', $spacingWidth), $command->getDescription()));
}
return 0;
}
private function getColumnWidth(array $commands): int
{
$widths = [];
foreach ($commands as $command) {
if (false === ($command instanceof BaseCommand)) {
continue;
}
if ($command instanceof BaseCommand) {
$widths[] = Helper::width($command->getName());
foreach ($command->getAliases() as $alias) {
$widths[] = Helper::width($alias);
}
} else {
$widths[] = Helper::width($command);
}
}
return $widths ? max($widths) + 2 : 0;
}
}

View File

@ -0,0 +1,84 @@
<?php declare(strict_types=1);
namespace App\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Input\InputOption;
use App\SelfUpdate\Config;
use App\Dploy;
class SelfUpdateCommand extends Command
{
protected function configure(): void
{
$this->setName('self-update');
$this->setDescription('Updates dploy to the latest version.');
$this->addOption('channel', null, InputOption::VALUE_OPTIONAL, sprintf('Sets the channel to update dploy from, available channels: %s', implode(', ', Dploy::CHANNELS)));
$this->addOption('setVersion', null, InputOption::VALUE_OPTIONAL, 'Sets the specific version to update.');
$this->setHelp(
<<<EOT
The <info>self-update</info> command checks dploy.cloudpanel.io for newer
versions of dploy and if found, installs the latest.
<info>dploy self-update</info>
EOT);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
try {
$container = $this->getContainer();
$dploy = $container->get('App\Dploy');
$config = new Config();
$channel = (string)$input->getOption('channel');
if (true === empty($channel)) {
$channel = $config->get('channel');
$channel = (false === empty($channel) ? $channel : Dploy::CHANNEL_STABLE);
}
$channels = $dploy->getChannels();
if (false === in_array($channel, $channels)) {
throw new \Exception(sprintf('%s is not a valid channel, available channels: %s', $channel, implode(', ', Dploy::CHANNELS)));
}
$config->set('channel', $channel);
$localFilename = realpath($_SERVER['argv'][0]);
if (false === $localFilename) {
$localFilename = $_SERVER['argv'][0];
}
if (false === file_exists($localFilename)) {
throw new \Exception(sprintf('Dploy update failed: the %s is not accessible.', $localFilename));
}
$latest = $dploy->getLatest($channel);
$latestVersion = $latest['version'] ?? '';
$currentVersion = $dploy->getVersion();
$updateVersion = $input->getOption('setVersion');
$updateVersion = (false === empty($updateVersion) ? $updateVersion : $latestVersion);
if ($currentVersion == $updateVersion) {
$output->writeln(sprintf('<info>You already use the latest dploy version %s (%s channel).</info>', $updateVersion, $channel));
} else {
if ($currentVersion < $updateVersion) {
$output->writeln(sprintf('Upgrading from <info>%s</info> to <info>%s</info> (%s channel).', $currentVersion, $updateVersion, $channel));
$output->writeln('');
$downloadedFile = $dploy->downloadVersion($updateVersion, $output);
$publicKey = sprintf('%s/data/keys/public.key', $this->getProjectDirectory());
$opensslPublicKey = openssl_pkey_get_public(file_get_contents($publicKey));
if (false === $opensslPublicKey) {
throw new \RuntimeException(sprintf('Failed loading the public key from: %s', $publicKey));
}
$signature = $dploy->getSignatureForVersion($updateVersion);
$verified = 1 === openssl_verify((string) file_get_contents($downloadedFile), $signature, $opensslPublicKey, OPENSSL_ALGO_SHA384);
if (false === $verified) {
throw new \RuntimeException('The phar signature did not match the file you downloaded, this means your public keys are outdated or that the phar file is corrupt/has been modified.');
}
@copy($downloadedFile, $localFilename);
$output->writeln(str_repeat(PHP_EOL, 1));
exit(Command::SUCCESS);
}
}
return Command::SUCCESS;
} catch (\Exception $e) {
$output->writeln(PHP_EOL.PHP_EOL.sprintf('<error>%s</error>', $e->getMessage()));
return Command::FAILURE;
}
}
}

46
src/Command/TestCommand.php Executable file
View File

@ -0,0 +1,46 @@
<?php declare(strict_types=1);
namespace App\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process;
class TestCommand extends Command
{
protected function configure(): void
{
$this->setName('test');
$this->setHidden(false);
$this->setDescription('dploy test');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
try {
/*
$process = Process::fromShellCommandline($command, '/tmp/');
$process->setTimeout(3600);
$process->run(function ($type, $buffer) use ($output) {
if (false === empty($buffer)) {
$output->write($buffer);
}
});
if (false === $process->isSuccessful()) {
throw new \RuntimeException($process->getErrorOutput());
}
*/
//$output->writeln(sprintf('Muha: %s', rand(1,1000)));
return Command::SUCCESS;
} catch (\Exception $e) {
$errorMessage = $e->getMessage();
$output->writeln(sprintf('<error>An error has occurred: "%s"</error>', $errorMessage));
return Command::FAILURE;
}
}
}

227
src/Compiler/Compiler.php Executable file
View File

@ -0,0 +1,227 @@
<?php declare(strict_types=1);
namespace App\Compiler;
use Composer\Pcre\Preg;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Filesystem\Filesystem;
class Compiler
{
private $version;
private $branchAliasVersion = '';
private $versionDate;
private $privateKey;
public function compile(string $version): void
{
$filesystem = new Filesystem();
$compiledDirectory = realpath(__DIR__.'/../../bin/compiled');
$filesystem->remove($compiledDirectory);
$filesystem->mkdir($compiledDirectory);
$pharFile = sprintf('%s/dploy.phar', $compiledDirectory);
$finalFile = sprintf('%s/dploy', $compiledDirectory);
$signatureFile = sprintf('%s/dploy.sig', $compiledDirectory);
if (file_exists($pharFile)) {
unlink($pharFile);
}
$phar = new \Phar($pharFile, 0, 'dploy.phar');
$phar->setSignatureAlgorithm(\Phar::SHA512);
$phar->startBuffering();
$finderSort = static function ($a, $b): int {
return strcmp(strtr($a->getRealPath(), '\\', '/'), strtr($b->getRealPath(), '\\', '/'));
};
$finder = new Finder();
$finder->files()
->ignoreVCS(true)
->name('*.php')
->notName('Compiler.php')
->notName('ClassLoader.php')
->notName('InstalledVersions.php')
->in(__DIR__.'/..')
->sort($finderSort)
;
foreach ($finder as $file) {
$this->addFile($phar, $file);
}
$envFile = new \SplFileInfo(__DIR__.'/../../.env');
$this->addFile($phar, $envFile);
// Add vendor files
$finder = new Finder();
$finder->files()
->ignoreVCS(true)
->notPath('/\/(composer\.(json|lock)|[A-Z]+\.md(?:own)?|\.gitignore|appveyor.yml|phpunit\.xml\.dist|phpstan\.neon\.dist|phpstan-config\.neon|phpstan-baseline\.neon)$/')
->notPath('/bin\/(jsonlint|validate-json|simple-phpunit|phpstan|phpstan\.phar)(\.bat)?$/')
->notPath('justinrainbow/json-schema/demo/')
->notPath('justinrainbow/json-schema/dist/')
->notPath('composer/installed.json')
->notPath('composer/LICENSE')
->notPath('keys/private.key')
->notPath('bin/patch-type-declarations')
->notPath('bin/var-dump-server')
->notPath('bin/yaml-lint')
->notPath('psr/cache/LICENSE.txt')
->notPath('symfony/console/Resources/bin/hiddeninput.exe')
->notPath('symfony/console/Resources/completion.bash')
->notPath('symfony/console/Resources/completion.fish')
->notPath('symfony/console/Resources/completion.zsh')
->notPath('symfony/dependency-injection/Loader/schema/dic/services/services-1.0.xsd')
->notPath('symfony/error-handler/Resources/assets/')
->notPath('symfony/var-dumper/Resources/css/htmlDescriptor.css')
->notPath('symfony/var-dumper/Resources/js/htmlDescriptor.js')
->notPath('symfony/runtime/Internal/autoload_runtime.template')
->notPath('symfony/routing/Loader/schema/routing/routing-1.0.xsd')
->notPath('symfony/framework-bundle/Resources/config/schema/symfony-1.0.xsd')
->notPath('symfony/framework-bundle/Resources/config/routing/errors.xml')
->exclude('Tests')
->exclude('tests')
->exclude('docs')
->in(__DIR__.'/../../vendor/')
->in(__DIR__.'/../../config/')
->in(__DIR__.'/../../data/')
->in(__DIR__.'/../../var/')
->sort($finderSort)
;
$extraFiles = [];
$unexpectedFiles = [];
foreach ($finder as $file) {
if (false !== ($index = array_search($file->getRealPath(), $extraFiles, true))) {
unset($extraFiles[$index]);
} elseif (!Preg::isMatch('{(^LICENSE$|\.php$)}', $file->getFilename())) {
//$unexpectedFiles[] = (string) $file;
}
if (Preg::isMatch('{\.php[\d.]*$}', $file->getFilename())) {
$this->addFile($phar, $file);
} else {
$this->addFile($phar, $file, false);
}
}
if (count($extraFiles) > 0) {
throw new \RuntimeException('These files were expected but not added to the phar, they might be excluded or gone from the source package:'.PHP_EOL.var_export($extraFiles, true));
}
if (count($unexpectedFiles) > 0) {
throw new \RuntimeException('These files were unexpectedly added to the phar, make sure they are excluded or listed in $extraFiles:'.PHP_EOL.var_export($unexpectedFiles, true));
}
$envFile = dirname(__FILE__).'/../../.env';
$envFileContent = file_get_contents($envFile);
$this->addDployBin($phar);
$phar->addFromString('composer.json', '');
$phar->addFromString('.env', $envFileContent);
$phar['.env']->chmod(0777);
$stub = $this->getStub();
$phar->setStub($stub);
//$phar->compressFiles(\Phar::GZ);
$phar->stopBuffering();
$privateKey = $this->getPrivateKey();
$private = openssl_get_privatekey(file_get_contents($privateKey));
openssl_sign((string)file_get_contents($pharFile), $signature, $private, OPENSSL_ALGO_SHA384);
file_put_contents($signatureFile, base64_encode($signature));
rename($pharFile, $finalFile);
unset($phar);
}
public function setPrivateKey(string $privateKey)
{
$this->privateKey = $privateKey;
}
public function getPrivateKey()
{
return $this->privateKey;
}
private function getRelativeFilePath(\SplFileInfo $file): string
{
$realPath = (string)$file->getRealPath();
$pathPrefix = dirname(__DIR__, 2).DIRECTORY_SEPARATOR;
$pos = strpos($realPath, $pathPrefix);
$relativePath = ($pos !== false) ? substr_replace($realPath, '', $pos, strlen($pathPrefix)) : $realPath;
return strtr($relativePath, '\\', '/');
}
private function addFile(\Phar $phar, \SplFileInfo $file, bool $strip = true): void
{
$path = $this->getRelativeFilePath($file);
$content = file_get_contents((string) $file);
if ($strip) {
//$content = $this->stripWhitespace($content);
} elseif ('LICENSE' === $file->getFilename()) {
$content = "\n".$content."\n";
}
if ($path === 'src/Composer/Composer.php') {
$content = strtr(
$content,
[
'@package_version@' => $this->version,
'@package_branch_alias_version@' => $this->branchAliasVersion,
'@release_date@' => $this->versionDate->format('Y-m-d H:i:s'),
]
);
$content = Preg::replace('{SOURCE_VERSION = \'[^\']+\';}', 'SOURCE_VERSION = \'\';', $content);
}
$phar->addFromString($path, $content);
$phar[$path]->chmod(0777);
}
private function addDployBin(\Phar $phar): void
{
$content = file_get_contents(__DIR__.'/../../bin/dploy');
//$content = Preg::replace('{^#!/usr/bin/env php8.2\s*}', '', $content);
$phar->addFromString('bin/dploy', $content);
}
private function stripWhitespace(string $source): string
{
if (!function_exists('token_get_all')) {
return $source;
}
$output = '';
foreach (token_get_all($source) as $token) {
if (is_string($token)) {
$output .= $token;
} elseif (in_array($token[0], [T_COMMENT, T_DOC_COMMENT])) {
$output .= str_repeat("\n", substr_count($token[1], "\n"));
} elseif (T_WHITESPACE === $token[0]) {
// reduce wide spaces
$whitespace = Preg::replace('{[ \t]+}', ' ', $token[1]);
// normalize newlines to \n
$whitespace = Preg::replace('{(?:\r\n|\r|\n)}', "\n", $whitespace);
// trim leading spaces
$whitespace = Preg::replace('{\n +}', "\n", $whitespace);
$output .= $whitespace;
} else {
$output .= $token[1];
}
}
return $output;
}
private function getStub(): string
{
$stub = <<<'EOF'
#!/usr/bin/env php8.2
<?php
if (!class_exists('Phar')) {
echo 'PHP\'s phar extension is missing.' . PHP_EOL;
exit(1);
}
Phar::mapPhar('dploy.phar');
EOF;
return $stub . <<<'EOF'
require 'phar://dploy.phar/bin/dploy';
__HALT_COMPILER();
EOF;
}
}

26
src/Compiler/bootstrap.php Executable file
View File

@ -0,0 +1,26 @@
<?php declare(strict_types=1);
/*
* This file is part of Composer.
*
* (c) Nils Adermann <naderman@naderman.de>
* Jordi Boggiano <j.boggiano@seld.be>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
use Composer\Autoload\ClassLoader;
function includeIfExists(string $file): ?ClassLoader
{
return file_exists($file) ? include $file : null;
}
if ((!$loader = includeIfExists(__DIR__.'/../../vendor/autoload.php')) && (!$loader = includeIfExists(__DIR__.'/../../../../autoload.php'))) {
echo 'You must set up the project dependencies using `composer install`'.PHP_EOL.
'See https://getcomposer.org/download/ for instructions on installing Composer'.PHP_EOL;
exit(1);
}
return $loader;

96
src/Console/Application.php Executable file
View File

@ -0,0 +1,96 @@
<?php
namespace App\Console;
use Symfony\Bundle\FrameworkBundle\Console\Application as BaseApplication;
use Symfony\Component\Console\Command\HelpCommand;
use Symfony\Component\Console\Command\CompleteCommand;
use Symfony\Component\Console\Command\DumpCompletionCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use App\Dploy;
use App\Command\InitCommand;
use App\Command\ListCommand;
use App\Command\DeployCommand;
use App\Command\SelfUpdateCommand;
class Application extends BaseApplication
{
const APPLICATION_NAME = 'Dploy';
const APPLICATION_LOGO = '
_____ _____ _ ______ __
| __ \| __ \| | / __ \ \ / /
| | | | |__) | | | | | \ \_/ /
| | | | ___/| | | | | |\ /
| |__| | | | |___| |__| | | |
|_____/|_| |______\____/ |_|
';
private KernelInterface $kernel;
public function __construct(KernelInterface $kernel)
{
$this->kernel = $kernel;
parent::__construct($this->kernel);
}
public function doRun(InputInterface $input, OutputInterface $output): int
{
$this->setApplicationNameAndVersion();
return parent::doRun($input, $output);
}
private function setApplicationNameAndVersion(): void
{
$version = Dploy::getVersion();
$this->setName(self::APPLICATION_NAME);
$this->setVersion($version);
}
private function init()
{
if ($this->initialized) {
return;
}
$this->initialized = true;
foreach ($this->getDefaultCommands() as $command) {
$this->add($command);
}
}
public function getHelp(): string
{
return self::APPLICATION_LOGO . parent::getHelp();
}
public function getLongVersion(): string
{
$name = $this->getName();
$version = $this->getVersion();
$longVersion = sprintf('<info>%s</info> version <comment>%s</comment>', $name, $version);
return $longVersion;
}
protected function getDefaultCommands(): array
{
$listCommand = new ListCommand();
$listCommand->setHidden(true);
$commands = [
new HelpCommand(),
$listCommand,
new CompleteCommand(),
new DumpCompletionCommand(),
new InitCommand(),
new DeployCommand(),
];
if (true === IS_PHAR) {
$commands[] = new SelfUpdateCommand();
}
return $commands;
}
public function getCommands(): array
{
return $this->getDefaultCommands();
}
public function getContainer(): ContainerInterface
{
return $this->kernel->getContainer();
}
}

0
src/Controller/.gitignore vendored Executable file
View File

View File

@ -0,0 +1,31 @@
<?php
namespace App\Deployment\Command;
use App\System\Command;
use App\System\Process;
class CommandExecutor
{
public function execute(Command $command, $timeout = 30): void
{
try {
$runInBackground = $command->runInBackground();
$process = Process::fromShellCommandline($command->getCommand(), '/tmp/');
$process->setCommand($command);
if (true === $runInBackground) {
$process->start();
} else {
$process->setTimeout($timeout);
$process->run();
if (false === $process->isSuccessful()) {
throw new \RuntimeException($process->getErrorOutput());
}
}
} catch (\Exception $e) {
$fullCommand = $command->getCommand();
$errorMessage = sprintf('Command "%s : %s" failed, error message: %s', $command->getName(), $fullCommand, $e->getMessage());
throw new \Exception($errorMessage);
}
}
}

117
src/Deployment/Config.php Executable file
View File

@ -0,0 +1,117 @@
<?php
namespace App\Deployment;
use Symfony\Component\Yaml\Yaml;
class Config
{
private array $data = [];
private ?bool $configParsed = null;
public function getGitRepository(): ?string
{
return $this->getValue('git_repository');
}
public function getDeployDirectory(): ?string
{
$deployConfig = $this->getValue('deploy');
$deployDirectory = $deployConfig['directory'] ?? '';
return $deployDirectory;
}
public function getOverlaysDirectory(): string
{
$deployConfigDirectory = $this->getConfigDirectory();
$overlaysDirectory = sprintf('%s/overlays', $deployConfigDirectory);
return $overlaysDirectory;
}
public function getConfigDirectory(): string
{
$systemUserName = $this->getSystemUserName();
$configDirectory = sprintf('/home/%s/.dploy', $systemUserName);
return $configDirectory;
}
public function getHomeDirectory()
{
$homeDirectory = $_SERVER['HOME'] ?? '';
return $homeDirectory;
}
public function getConfigFile(): string
{
$configDirectory = $this->getConfigDirectory();
$configFile = sprintf('%s/config.yml', $configDirectory);
return $configFile;
}
public function getReleasesDirectory(): string
{
$deployDirectory = $this->getDeployDirectory();
$releasesDirectory = sprintf('%s/releases', $deployDirectory);
return $releasesDirectory;
}
public function getSharedDirectory(): string
{
$deployDirectory = $this->getDeployDirectory();
$sharedDirectory = sprintf('%s/shared', $deployDirectory);
return $sharedDirectory;
}
public function getSharedDirectories(): array
{
$deployConfig = $this->getValue('deploy');
$sharedDirectories = (true == isset($deployConfig['shared_directories']) ? (array)$deployConfig['shared_directories'] : []);
return $sharedDirectories;
}
public function getBeforeDeployCommands(): array
{
$deployConfig = $this->getValue('deploy');
$beforeDeployCommands = (true == isset($deployConfig['before_commands']) ? (array)$deployConfig['before_commands'] : []);
return $beforeDeployCommands;
}
public function getAfterDeployCommands(): array
{
$deployConfig = $this->getValue('deploy');
$afterDeployCommands = (true == isset($deployConfig['after_commands']) ? (array)$deployConfig['after_commands'] : []);
return $afterDeployCommands;
}
private function parseConfigFile(): void
{
if (true === is_null($this->configParsed)) {
$configFile = $this->getConfigFile();
$data = Yaml::parseFile($configFile);
if (true === isset($data['project'])) {
$this->data = $data['project'];
}
$this->configParsed = true;
}
}
private function getValue(string $key): mixed
{
$this->parseConfigFile();
$value = $this->data[$key] ?? '';
return $value;
}
public function getSystemUserName(): string
{
$systemUserId = $this->getSystemUserId();
$processUser = posix_getpwuid($systemUserId);
$systemUserName = $processUser['name'];
return $systemUserName;
}
public function getSystemUserId(): int
{
$systemUserId = posix_geteuid();
return $systemUserId;
}
}

131
src/Deployment/Deployment.php Executable file
View File

@ -0,0 +1,131 @@
<?php
namespace App\Deployment;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Filesystem\Filesystem;
use App\Deployment\Task\GitCloneRepository as GitCloneRepositoryTask;
use App\Deployment\Task\CopyOverlayFiles as CopyOverlayFilesTask;
use App\Deployment\Task\SymlinkSharedDirectories as SymlinkSharedDirectoriesTask;
use App\Deployment\Task\ExecuteCommand as ExecuteCommandTask;
use App\Deployment\Task\SetPermissions as SetPermissionsTask;
use App\Deployment\Task\ReleaseSwitch as ReleaseSwitchTask;
use App\Deployment\Task\CleanUpReleases as CleanUpReleasesTask;
class Deployment
{
private ?string $releaseName = null;
private array $tasks = [];
public function __construct(
private OutputInterface $output,
private readonly Config $config,
private readonly string $version
) {
}
public function deploy(): void
{
$this->validate();
$this->executeTasks();
}
private function executeTasks(): void
{
$this->addTasks();
foreach ($this->tasks as $task) {
$this->output->writeln(sprintf('<comment>%s ...</comment>', $task->getDescription()));
$task->run();
}
}
private function addTasks(): void
{
$gitCloneRepositoryTask = new GitCloneRepositoryTask($this);
$copyOverlayFilesTask = new CopyOverlayFilesTask($this);
$symlinkSharedDirectoriesTask = new SymlinkSharedDirectoriesTask($this);
$this->tasks = array_merge([
$gitCloneRepositoryTask,
$copyOverlayFilesTask,
$symlinkSharedDirectoriesTask
], $this->tasks);
$beforeCommands = $this->config->getBeforeDeployCommands();
if (false === empty($beforeCommands)) {
foreach ($beforeCommands as $command) {
if (false === empty($command)) {
$executeCommandTask = new ExecuteCommandTask($this);
$executeCommandTask->setCommand($command);
$this->tasks[] = $executeCommandTask;
}
}
}
//$setPermissionsTask = new SetPermissionsTask($this);
$releaseSwitchTask = new ReleaseSwitchTask($this);
$this->tasks = array_merge($this->tasks, [$releaseSwitchTask]);
$afterCommands = $this->config->getAfterDeployCommands();
if (false === empty($afterCommands)) {
foreach ($afterCommands as $command) {
if (false === empty($command)) {
$executeCommandTask = new ExecuteCommandTask($this);
$executeCommandTask->setCommand($command);
$this->tasks[] = $executeCommandTask;
}
}
}
$cleanUpReleasesTask = new CleanUpReleasesTask($this);
$this->tasks[] = $cleanUpReleasesTask;
}
public function getVersion(): string
{
return $this->version;
}
public function getOutput(): OutputInterface
{
return $this->output;
}
public function getReleaseName(): string
{
if (true === is_null($this->releaseName)) {
$dateTime = new \DateTime('now');
$this->releaseName = sprintf('%s-%s', $dateTime->format('Y-m-d-H-i-s'), $this->version);
}
return $this->releaseName;
}
public function getReleaseDirectory(): string
{
$releaseName = $this->getReleaseName();
$releasesDirectory = $this->config->getReleasesDirectory();
$releaseDirectory = sprintf('%s/%s', $releasesDirectory, $releaseName);
return $releaseDirectory;
}
public function getConfig(): Config
{
return $this->config;
}
private function validate(): void
{
$filesystem = new Filesystem();
$deployDirectory = $this->config->getDeployDirectory();
$systemUserName = $this->config->getSystemUserName();
if (false === $filesystem->exists($deployDirectory)) {
throw new \Exception(sprintf('Deploy directory "%s" does not exist.', $deployDirectory));
}
if (false === str_starts_with($deployDirectory, sprintf('/home/%s/htdocs', $systemUserName))) {
throw new \Exception(sprintf('System User "%s" is not part of the deploy directory: "%s"', $systemUserName, $deployDirectory));
}
$overlaysDirectory = $this->config->getOverlaysDirectory();
if (false === $filesystem->exists($overlaysDirectory)) {
throw new \Exception(sprintf('Overlays directory "%s" does not exist.', $overlaysDirectory));
}
$gitRepository = $this->config->getGitRepository();
if (true === empty($gitRepository)) {
throw new \Exception('Git repository cannot be empty.');
}
}
}

53
src/Deployment/Setup.php Executable file
View File

@ -0,0 +1,53 @@
<?php
namespace App\Deployment;
use Symfony\Component\Filesystem\Filesystem;
class Setup
{
private const DIRECTORY_CHMOD = 0760;
public function __construct(
private readonly Config $config
) {
}
public function create(): void
{
$gitRepository = $this->config->getGitRepository();
$deployDirectory = $this->config->getDeployDirectory();
if (true === empty($gitRepository)) {
throw new \Exception('git repository cannot be empty.');
}
if (true === empty($deployDirectory)) {
throw new \Exception('deploy directory cannot be empty.');
}
$filesystem = new Filesystem();
$configDirectory = $this->config->getConfigDirectory();
if (false === $filesystem->exists($configDirectory)) {
$filesystem->mkdir($configDirectory, self::DIRECTORY_CHMOD);
}
$releasesDirectory = $this->config->getReleasesDirectory();
if (false === $filesystem->exists($releasesDirectory)) {
$filesystem->mkdir($releasesDirectory, self::DIRECTORY_CHMOD);
}
$overlaysDirectory = $this->config->getOverlaysDirectory();
if (false === $filesystem->exists($overlaysDirectory)) {
$filesystem->mkdir($overlaysDirectory, self::DIRECTORY_CHMOD);
}
$sharedDirectory = $this->config->getSharedDirectory();
if (false === $filesystem->exists($sharedDirectory)) {
$filesystem->mkdir($sharedDirectory, self::DIRECTORY_CHMOD);
}
$sharedDirectories = $this->config->getSharedDirectories();
if (false === empty($sharedDirectories)) {
foreach ($sharedDirectories as $directory) {
$directory = sprintf('%s/%s', $sharedDirectory, $directory);
if (false === $filesystem->exists($directory)) {
$filesystem->mkdir($directory, self::DIRECTORY_CHMOD);
}
}
}
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Deployment\Task;
use App\Deployment\Task\Task as BaseTask;
class CleanUpReleases extends BaseTask
{
protected string $description = 'Clean Up Releases';
public function run(): void
{
$deployment = $this->getDeployment();
$config = $deployment->getConfig();
$releasesDirectory = $config->getReleasesDirectory();
$command = sprintf('cd %s && ls -t | tail -n +4 | xargs rm -rf', $releasesDirectory);
$this->runCommand($command);
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Deployment\Task;
use Symfony\Component\Filesystem\Filesystem;
use App\Deployment\Task\Task as BaseTask;
class CopyOverlayFiles extends BaseTask
{
protected string $description = 'Copying Overlay Files';
public function run(): void
{
$deployment = $this->getDeployment();
$config = $deployment->getConfig();
$releaseDirectory = $deployment->getReleaseDirectory();
$overlaysDirectory = $config->getOverlaysDirectory();
$filesystem = new Filesystem();
$objects = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($overlaysDirectory), \RecursiveIteratorIterator::SELF_FIRST);
foreach($objects as $object){
if (true === $object->isFile()) {
$originFile = $object->getRealPath();
$targetFile = str_replace($overlaysDirectory, $releaseDirectory, $originFile);
$filesystem->copy($originFile, $targetFile);
}
}
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Deployment\Task;
use Symfony\Component\Process\Process;
use App\Deployment\Task\Task as BaseTask;
class ExecuteCommand extends BaseTask
{
protected string $description = 'Executing Command';
private string $command;
public function run(): void
{
$deployment = $this->getDeployment();
$output = $deployment->getOutput();
$command = $this->getCommand();
$process = Process::fromShellCommandline($command, '/tmp/');
$process->setTimeout(3600);
$process->run(function ($type, $buffer) use ($output) {
if (false === empty($buffer)) {
$output->write($buffer);
}
});
if (false === $process->isSuccessful()) {
throw new \RuntimeException($process->getErrorOutput());
}
}
public function getDescription(): string
{
$command = $this->getCommand();
$this->description = sprintf('%s: %s', $this->description, $command);
return $this->description;
}
public function setCommand(string $command): void
{
$this->command = $command;
}
public function getCommand(): string
{
$deployment = $this->getDeployment();
$releaseDirectory = $deployment->getReleaseDirectory();
if (true === str_contains($this->command, '{release_directory}')) {
$this->command = str_replace('{release_directory}', $releaseDirectory, $this->command);
}
return $this->command;
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Deployment\Task;
use Symfony\Component\Process\Process;
use Symfony\Component\Filesystem\Filesystem;
use App\Deployment\Task\Task as BaseTask;
class GitCloneRepository extends BaseTask
{
protected string $description = 'Cloning Git Repository';
public function run(): void
{
$deployment = $this->getDeployment();
$config = $deployment->getConfig();
$gitRepository = $config->getGitRepository();
$version = $deployment->getVersion();
$releaseDirectory = $deployment->getReleaseDirectory();
$command = sprintf('/usr/bin/git clone -c advice.detachedHead=false --progress --depth 1 --branch %s %s %s', escapeshellarg($version), escapeshellarg($gitRepository), escapeshellarg($releaseDirectory));
$output = $deployment->getOutput();
$process = Process::fromShellCommandline($command, '/tmp/');
$process->setTimeout(600);
$process->run(function ($type, $buffer) use ($output) {
if (false === empty($buffer)) {
$output->write($buffer);
}
});
if (false === $process->isSuccessful()) {
throw new \RuntimeException($process->getErrorOutput());
}
$gitDirectory = sprintf('%s/.git', $releaseDirectory);
$filesystem = new Filesystem();
if (true === $filesystem->exists($gitDirectory)) {
$filesystem->remove($gitDirectory);
}
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Deployment\Task;
use App\Deployment\Task\Task as BaseTask;
class ReleaseSwitch extends BaseTask
{
protected string $description = 'Switching Release';
public function run(): void
{
$deployment = $this->getDeployment();
$config = $deployment->getConfig();
$deployDirectory = $config->getDeployDirectory();
$releaseDirectory = $deployment->getReleaseDirectory();
$currentDirectory = sprintf('%s/current', $deployDirectory);
$command = sprintf('ln -sfn %s %s', $releaseDirectory, $currentDirectory);
$this->runCommand($command);
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Deployment\Task;
use App\Deployment\Task\Task as BaseTask;
class SetPermissions extends BaseTask
{
private const DIRECTORY_CHMOD = 770;
private const FILE_CHMOD = 660;
protected string $description = 'Setting Permissions';
public function run(): void
{
$deployment = $this->getDeployment();
$releaseDirectory = $deployment->getReleaseDirectory();
$command = sprintf('/usr/bin/find %s -type d -exec chmod %s {} \; && /usr/bin/find %s -type f -exec chmod %s {} \;',
escapeshellarg($releaseDirectory),
self::DIRECTORY_CHMOD,
escapeshellarg($releaseDirectory),
self::FILE_CHMOD
);
$this->runCommand($command);
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Deployment\Task;
use App\Deployment\Task\Task as BaseTask;
class SymlinkSharedDirectories extends BaseTask
{
protected string $description = 'Symlink Shared Directories';
public function run(): void
{
$deployment = $this->getDeployment();
$config = $deployment->getConfig();
$releaseDirectory = $deployment->getReleaseDirectory();
$sharedDirectories = $config->getSharedDirectories();
if (false === empty($sharedDirectories)) {
foreach ($sharedDirectories as $sharedDirectory) {
$sharedDirectory = rtrim(ltrim($sharedDirectory, '/'), '/');
$sharedDirectoryPath = sprintf('%s/%s', rtrim($releaseDirectory, '/'), $sharedDirectory);
$deleteDestinationCommand = sprintf('rm -rf %s', $sharedDirectoryPath);
$this->runCommand($deleteDestinationCommand);
$symlinkCommand = sprintf('cd %s && ln -sfrn ../../shared/%s %s', $releaseDirectory, $sharedDirectory, $sharedDirectory);
$this->runCommand($symlinkCommand);
}
}
}
}

41
src/Deployment/Task/Task.php Executable file
View File

@ -0,0 +1,41 @@
<?php
namespace App\Deployment\Task;
use Symfony\Component\Process\Process;
use App\Deployment\Deployment;
abstract class Task
{
protected string $description;
public function __construct(
private Deployment $deployment
)
{
}
public function run()
{
}
public function getDescription(): string
{
return $this->description;
}
public function getDeployment(): Deployment
{
return $this->deployment;
}
protected function runCommand(string $command, $timeout = 900)
{
$process = Process::fromShellCommandline($command, '/tmp/');
$process->setTimeout($timeout);
$process->run();
if (false === $process->isSuccessful()) {
throw new \RuntimeException($process->getErrorOutput());
}
}
}

111
src/Dploy.php Executable file
View File

@ -0,0 +1,111 @@
<?php declare(strict_types=1);
namespace App;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Component\Console\Helper\ProgressBar;
class Dploy
{
private const BASE_URL = 'https://dploy.cloudpanel.io';
private const REQUEST_TIMEOUT = '30';
public const CHANNEL_STABLE = 'stable';
public const CHANNELS = ['preview', 'stable'];
private float $percentageDownloaded = 0;
private ?string $tmpFile;
public function __construct(
private HttpClientInterface $httpClient,
)
{
}
public function downloadVersion(string $version, OutputInterface $output): string
{
$downloadedFile = '';
$remoteFile = sprintf('%s/download/%s/dploy', self::BASE_URL, $version);
$progressBar = new ProgressBar($output, 100);
$progressBar->setFormat('verbose');
$this->percentageDownloaded = 0;
$response = $this->httpClient->request('GET', $remoteFile, [
'on_progress' => function (int $downloadedInBytes, int $downloadFilesizeInBytes, array $info) use ($progressBar): void {
if ($downloadedInBytes > 0) {
$downloadedInMegabyte = round($downloadedInBytes/1000000);
$downloadFilesizeInMegabyte = round($downloadFilesizeInBytes/1000000);
$percentageDownloaded = (int)round(($downloadedInMegabyte/$downloadFilesizeInMegabyte)*100);
if ($percentageDownloaded > 0 && $percentageDownloaded > $this->percentageDownloaded) {
$this->percentageDownloaded = $percentageDownloaded;
$progressBar->setProgress($percentageDownloaded);
}
}
},
]);
if (200 == $response->getStatusCode()) {
$this->tmpFile = tempnam(sys_get_temp_dir(), '');
$tmpFile = sprintf('%s.phar', $this->tmpFile);
rename($this->tmpFile, $tmpFile);
$this->tmpFile = $tmpFile;
$body = $response->getContent();
file_put_contents($this->tmpFile, $body);
$downloadedFile = $this->tmpFile;
} else {
throw new \Exception(sprintf('Cannot download file %s, status code: %s', $remoteFile, $response->getStatusCode()));
}
return $downloadedFile;
}
public function getSignatureForVersion(string $version): string
{
$signature = '';
$signatureFile = sprintf('%s/download/%s/dploy.sig', self::BASE_URL, $version);
$response = $this->httpClient->request('GET', $signatureFile, ['timeout' => self::REQUEST_TIMEOUT]);
$statusCode = $response->getStatusCode();
if (200 == $statusCode) {
$signature = (string)$response->getContent();
$signature = base64_decode($signature);
} else {
throw new \Exception(sprintf('Signature file %s not available.', $signatureFile));
}
return $signature;
}
public function getLatest(string $channel)
{
$latest = $this->getVersionsData($channel);
return $latest;
}
private function getVersionsData(string $channel): array
{
$requestUrl = sprintf('%s/versions.json', self::BASE_URL);
$response = $this->httpClient->request('GET', $requestUrl, ['timeout' => self::REQUEST_TIMEOUT]);
$statusCode = $response->getStatusCode();
if (200 == $statusCode) {
$versionsData = (array)$response->toArray();
if (true === isset($versionsData[$channel]) && false === empty($versionsData[$channel])) {
$versionsData = $versionsData[$channel];
return $versionsData;
} else {
throw new \Exception(sprintf('No versions data available for channel %s.', $channel));
}
} else {
throw new \Exception(sprintf('Versions file %s not available.', $requestUrl));
}
}
static public function getChannels(): array
{
return self::CHANNELS;
}
static public function getVersion(): string
{
$version = $_ENV['APP_VERSION'] ?? '';
return $version;
}
public function __destruct()
{
if (true === isset($this->tmpFile) && true === file_exists($this->tmpFile)) {
@unlink($this->tmpFile);
}
}
}

38
src/Kernel.php Executable file
View File

@ -0,0 +1,38 @@
<?php
namespace App;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
class Kernel extends BaseKernel
{
use MicroKernelTrait;
public function boot()
{
date_default_timezone_set('UTC');
setlocale(LC_CTYPE, "en_US.UTF-8");
parent::boot();
}
private function getHomeDirectory()
{
$homeDirectory = $_SERVER['HOME'];
return $homeDirectory;
}
public function getCacheDir(): string
{
$homeDirectory = $this->getHomeDirectory();
$cacheDir = sprintf('%s/.dploy/.app/cache', $homeDirectory);
return $cacheDir;
}
public function getLogDir(): string
{
$homeDirectory = $this->getHomeDirectory();
$logDir = sprintf('%s/.dploy/.app/logs', $homeDirectory);
return $logDir;
}
}

60
src/SelfUpdate/Config.php Executable file
View File

@ -0,0 +1,60 @@
<?php declare(strict_types=1);
namespace App\SelfUpdate;
use Symfony\Component\Filesystem\Filesystem;
class Config
{
public function get(string $key): mixed
{
switch ($key) {
case 'dploy-directory':
$homeDirectory = $this->get('home-directory');
$dployDirectory = sprintf('%s/.dploy', $homeDirectory);
return $dployDirectory;
case 'cache-directory':
$dployDirectory = $this->get('dploy-directory');
$cacheDirectory = sprintf('/home/%s/.cache', $dployDirectory);
return $cacheDirectory;
case 'home-directory':
$homeDirectory = $_SERVER['HOME'] ?? '';
return $homeDirectory;
case 'channel':
$channel = '';
$dployDirectory = $this->get('dploy-directory');
$channelFile = sprintf('%s/.channel', $dployDirectory);
if (true === file_exists($channelFile)) {
$channel = trim(file_get_contents($channelFile));
}
return $channel;
case 'channel-file':
$dployDirectory = $this->get('dploy-directory');
$channelFile = sprintf('%s/.channel', $dployDirectory);
return $channelFile;
}
}
public function set(string $key, string $value): void
{
$filesystem = new Filesystem();
$dployDirectory = $this->get('dploy-directory');
if (false === file_exists($dployDirectory)) {
$filesystem->mkdir($dployDirectory, 0770);
}
switch ($key) {
case 'channel':
$channelFile = $this->get('channel-file');
file_put_contents($channelFile, $value);
break;
}
}
static public function getSystemUserName(): string
{
$processUser = posix_getpwuid(posix_geteuid());
$systemUserName = $processUser['name'];
return $systemUserName;
}
}

23
src/Util/Retry.php Executable file
View File

@ -0,0 +1,23 @@
<?php declare(strict_types=1);
namespace App\Util;
class Retry
{
static public function retry(callable $fn, $retries = 2, $delay = 5): mixed
{
beginning:
try {
return $fn();
} catch (\Exception $e) {
if (!$retries) {
throw $e;
}
$retries--;
if ($delay) {
sleep($delay);
}
goto beginning;
}
}
}

58
symfony.lock Executable file
View File

@ -0,0 +1,58 @@
{
"symfony/console": {
"version": "6.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "5.3",
"ref": "da0c8be8157600ad34f10ff0c9cc91232522e047"
},
"files": [
"bin/console"
]
},
"symfony/flex": {
"version": "2.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "146251ae39e06a95be0fe3d13c807bcf3938b172"
},
"files": [
".env"
]
},
"symfony/framework-bundle": {
"version": "6.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.2",
"ref": "af47254c5e4cd543e6af3e4508298ffebbdaddd3"
},
"files": [
"config/packages/cache.yaml",
"config/packages/framework.yaml",
"config/preload.php",
"config/routes/framework.yaml",
"config/services.yaml",
"public/index.php",
"src/Controller/.gitignore",
"src/Kernel.php"
]
},
"symfony/routing": {
"version": "6.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.2",
"ref": "e0a11b4ccb8c9e70b574ff5ad3dfdcd41dec5aa6"
},
"files": [
"config/packages/routing.yaml",
"config/routes.yaml"
]
}
}