Passed
Push — trunk ( 2eab1a...f65a85 )
by Christian
13:15 queued 15s
created

SystemSetupCommand::generateInstanceId()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 12
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 7
nc 2
nop 0
dl 0
loc 12
rs 10
c 0
b 0
f 0
1
<?php declare(strict_types=1);
2
3
namespace Shopware\Core\Maintenance\System\Command;
4
5
use Defuse\Crypto\Key;
6
use Doctrine\DBAL\Configuration;
7
use Doctrine\DBAL\DriverManager;
8
use Shopware\Core\DevOps\Environment\EnvironmentHelper;
9
use Shopware\Core\Maintenance\System\Service\JwtCertificateGenerator;
10
use Symfony\Component\Console\Command\Command;
11
use Symfony\Component\Console\Input\ArrayInput;
12
use Symfony\Component\Console\Input\InputInterface;
13
use Symfony\Component\Console\Input\InputOption;
14
use Symfony\Component\Console\Output\OutputInterface;
15
use Symfony\Component\Console\Question\ConfirmationQuestion;
16
use Symfony\Component\Console\Style\OutputStyle;
17
use Symfony\Component\Console\Style\SymfonyStyle;
18
use Symfony\Component\Dotenv\Command\DotenvDumpCommand;
19
20
/**
21
 * @internal should be used over the CLI only
22
 */
23
class SystemSetupCommand extends Command
24
{
25
    public static $defaultName = 'system:setup';
26
27
    private string $projectDir;
28
29
    private JwtCertificateGenerator $jwtCertificateGenerator;
30
31
    private DotenvDumpCommand $dumpEnvCommand;
32
33
    public function __construct(string $projectDir, JwtCertificateGenerator $jwtCertificateGenerator, DotenvDumpCommand $dumpEnvCommand)
34
    {
35
        parent::__construct();
36
        $this->projectDir = $projectDir;
37
        $this->jwtCertificateGenerator = $jwtCertificateGenerator;
38
        $this->dumpEnvCommand = $dumpEnvCommand;
39
    }
40
41
    protected function configure(): void
42
    {
43
        $this
44
            ->addOption('force', 'f', InputOption::VALUE_NONE, 'Force setup and recreate everything')
45
            ->addOption('no-check-db-connection', null, InputOption::VALUE_NONE, 'dont check db connection')
46
            ->addOption('database-url', null, InputOption::VALUE_OPTIONAL, 'Database dsn', $this->getDefault('DATABASE_URL', ''))
47
            ->addOption('database-ssl-ca', null, InputOption::VALUE_OPTIONAL, 'Database SSL CA path', $this->getDefault('DATABASE_SSL_CA', ''))
48
            ->addOption('database-ssl-cert', null, InputOption::VALUE_OPTIONAL, 'Database SSL Cert path', $this->getDefault('DATABASE_SSL_CERT', ''))
49
            ->addOption('database-ssl-key', null, InputOption::VALUE_OPTIONAL, 'Database SSL Key path', $this->getDefault('DATABASE_SSL_KEY', ''))
50
            ->addOption('database-ssl-dont-verify-cert', null, InputOption::VALUE_OPTIONAL, 'Database Don\'t verify server cert', $this->getDefault('DATABASE_SSL_DONT_VERIFY_SERVER_CERT', ''))
51
            ->addOption('generate-jwt-keys', null, InputOption::VALUE_NONE, 'Generate jwt private and public key')
52
            ->addOption('jwt-passphrase', null, InputOption::VALUE_OPTIONAL, 'JWT private key passphrase', 'shopware')
53
            ->addOption('composer-home', null, InputOption::VALUE_REQUIRED, 'Set the composer home directory otherwise the environment variable $COMPOSER_HOME will be used or the project dir as fallback', $this->getDefault('COMPOSER_HOME', ''))
54
            ->addOption('app-env', null, InputOption::VALUE_OPTIONAL, 'Application environment', $this->getDefault('APP_ENV', 'prod'))
55
            ->addOption('app-url', null, InputOption::VALUE_OPTIONAL, 'Application URL', $this->getDefault('APP_URL', 'http://localhost'))
56
            ->addOption('blue-green', null, InputOption::VALUE_OPTIONAL, 'Blue green deployment', $this->getDefault('BLUE_GREEN_DEPLOYMENT', '1'))
57
            ->addOption('es-enabled', null, InputOption::VALUE_OPTIONAL, 'Elasticsearch enabled', $this->getDefault('SHOPWARE_ES_ENABLED', '0'))
58
            ->addOption('es-hosts', null, InputOption::VALUE_OPTIONAL, 'Elasticsearch Hosts', $this->getDefault('SHOPWARE_ES_HOSTS', 'elasticsearch:9200'))
59
            ->addOption('es-indexing-enabled', null, InputOption::VALUE_OPTIONAL, 'Elasticsearch Indexing enabled', $this->getDefault('SHOPWARE_ES_INDEXING_ENABLED', '0'))
60
            ->addOption('es-index-prefix', null, InputOption::VALUE_OPTIONAL, 'Elasticsearch Index prefix', $this->getDefault('SHOPWARE_ES_INDEX_PREFIX', 'sw'))
61
            ->addOption('http-cache-enabled', null, InputOption::VALUE_OPTIONAL, 'Http-Cache enabled', $this->getDefault('SHOPWARE_HTTP_CACHE_ENABLED', '1'))
62
            ->addOption('http-cache-ttl', null, InputOption::VALUE_OPTIONAL, 'Http-Cache TTL', $this->getDefault('SHOPWARE_HTTP_DEFAULT_TTL', '7200'))
63
            ->addOption('cdn-strategy', null, InputOption::VALUE_OPTIONAL, 'CDN Strategy', $this->getDefault('SHOPWARE_CDN_STRATEGY_DEFAULT', 'id'))
64
            ->addOption('mailer-url', null, InputOption::VALUE_OPTIONAL, 'Mailer URL', $this->getDefault('MAILER_URL', 'native://default'))
65
            ->addOption('dump-env', null, InputOption::VALUE_NONE, 'Dump the generated .env file in a optimized .env.local.php file, to skip parsing of the .env file on each request');
66
    }
67
68
    protected function execute(InputInterface $input, OutputInterface $output): int
69
    {
70
        /** @var array<string, string> $env */
71
        $env = [
72
            'APP_ENV' => $input->getOption('app-env'),
73
            'APP_URL' => trim($input->getOption('app-url')), /* @phpstan-ignore-line */
74
            'DATABASE_URL' => $input->getOption('database-url'),
75
            'SHOPWARE_ES_HOSTS' => $input->getOption('es-hosts'),
76
            'SHOPWARE_ES_ENABLED' => $input->getOption('es-enabled'),
77
            'SHOPWARE_ES_INDEXING_ENABLED' => $input->getOption('es-indexing-enabled'),
78
            'SHOPWARE_ES_INDEX_PREFIX' => $input->getOption('es-index-prefix'),
79
            'SHOPWARE_HTTP_CACHE_ENABLED' => $input->getOption('http-cache-enabled'),
80
            'SHOPWARE_HTTP_DEFAULT_TTL' => $input->getOption('http-cache-ttl'),
81
            'SHOPWARE_CDN_STRATEGY_DEFAULT' => $input->getOption('cdn-strategy'),
82
            'BLUE_GREEN_DEPLOYMENT' => $input->getOption('blue-green'),
83
            'MAILER_URL' => $input->getOption('mailer-url'),
84
            'COMPOSER_HOME' => $input->getOption('composer-home'),
85
        ];
86
87
        if ($ca = $input->getOption('database-ssl-ca')) {
88
            $env['DATABASE_SSL_CA'] = $ca;
89
        }
90
91
        if ($cert = $input->getOption('database-ssl-cert')) {
92
            $env['DATABASE_SSL_CERT'] = $cert;
93
        }
94
95
        if ($certKey = $input->getOption('database-ssl-key')) {
96
            $env['DATABASE_SSL_KEY'] = $certKey;
97
        }
98
99
        if ($input->getOption('database-ssl-dont-verify-cert')) {
100
            $env['DATABASE_SSL_DONT_VERIFY_SERVER_CERT'] = '1';
101
        }
102
103
        if (empty($env['COMPOSER_HOME'])) {
104
            $env['COMPOSER_HOME'] = $this->projectDir . '/var/cache/composer';
105
        }
106
107
        $io = new SymfonyStyle($input, $output);
108
109
        if (file_exists($this->projectDir . '/symfony.lock')) {
110
            $io->warning('It looks like you have installed Shopware with Symfony Flex. You should use a .env.local file instead of creating a complete new one');
111
        }
112
113
        $io->title('Shopware setup process');
114
        $io->text('This tool will setup your instance.');
115
116
        if (!$input->getOption('force') && file_exists($this->projectDir . '/.env')) {
117
            $io->comment('Instance has already been set-up. To start over, please delete your .env file.');
118
119
            return 0;
120
        }
121
122
        if (!$input->isInteractive()) {
123
            $this->generateJwt($input, $io);
124
            $key = Key::createNewRandomKey();
125
            $env['APP_SECRET'] = $key->saveToAsciiSafeString();
126
            $env['INSTANCE_ID'] = $this->generateInstanceId();
127
128
            $this->createEnvFile($input, $io, $env);
129
130
            return 0;
131
        }
132
133
        $io->section('Application information');
134
        $env['APP_ENV'] = $io->choice('Application environment', ['prod', 'dev'], $input->getOption('app-env'));
135
136
        // TODO: optionally check http connection (create test file in public and request)
137
        $env['APP_URL'] = $io->ask('URL to your /public folder', $input->getOption('app-url'), static function (string $value): string {
138
            $value = trim($value);
139
140
            if ($value === '') {
141
                throw new \RuntimeException('Shop URL is required.');
142
            }
143
144
            if (!filter_var($value, \FILTER_VALIDATE_URL)) {
145
                throw new \RuntimeException('Invalid URL.');
146
            }
147
148
            return $value;
149
        });
150
151
        $io->section('Application information');
152
        $env['BLUE_GREEN_DEPLOYMENT'] = $io->confirm('Blue Green Deployment', $input->getOption('blue-green') !== '0') ? '1' : '0';
153
154
        $io->section('Generate keys and secrets');
155
156
        $this->generateJwt($input, $io);
157
158
        $key = Key::createNewRandomKey();
159
        $env['APP_SECRET'] = $key->saveToAsciiSafeString();
160
        $env['INSTANCE_ID'] = $this->generateInstanceId();
161
162
        $io->section('Database information');
163
164
        do {
165
            try {
166
                $exception = null;
167
                $env = array_merge($env, $this->getDsn($input, $io));
168
            } catch (\Throwable $e) {
169
                $exception = $e;
170
                $io->error($exception->getMessage());
171
            }
172
        } while ($exception && $io->confirm('Retry?', false));
173
174
        if ($exception) {
0 ignored issues
show
introduced by
$exception is of type Throwable|null, thus it always evaluated to false.
Loading history...
175
            throw $exception;
176
        }
177
178
        $this->createEnvFile($input, $io, $env);
179
180
        return 0;
181
    }
182
183
    /**
184
     * @return array<string, string>
185
     */
186
    private function getDsn(InputInterface $input, SymfonyStyle $io): array
187
    {
188
        $env = [];
189
190
        $emptyValidation = static function (string $value): string {
191
            if (trim($value) === '') {
192
                throw new \RuntimeException('This value is required.');
193
            }
194
195
            return $value;
196
        };
197
198
        $dbUser = $io->ask('Database user', 'app', $emptyValidation);
199
        $dbPass = $io->askHidden('Database password') ?: '';
200
        $dbHost = $io->ask('Database host', 'localhost', $emptyValidation);
201
        $dbPort = $io->ask('Database port', '3306', $emptyValidation);
202
        $dbName = $io->ask('Database name', 'shopware', $emptyValidation);
203
        $dbSslCa = $io->ask('Database SSL CA Path', '');
204
        $dbSslCert = $io->ask('Database SSL Cert Path', '');
205
        $dbSslKey = $io->ask('Database SSL Key Path', '');
206
        $dbSslDontVerify = $io->askQuestion(new ConfirmationQuestion('Skip verification of the database server\'s SSL certificate?', false));
207
208
        $dsnWithoutDb = sprintf(
209
            'mysql://%s:%s@%s:%d',
210
            $dbUser,
211
            rawurlencode($dbPass),
212
            $dbHost,
213
            $dbPort
214
        );
215
        $dsn = $dsnWithoutDb . '/' . $dbName;
216
217
        $params = ['url' => $dsnWithoutDb, 'charset' => 'utf8mb4'];
218
219
        if ($dbSslCa) {
220
            $params['driverOptions'][\PDO::MYSQL_ATTR_SSL_CA] = $dbSslCa;
221
            $env['DATABASE_SSL_CA'] = $dbSslCa;
222
        }
223
224
        if ($dbSslCert) {
225
            $params['driverOptions'][\PDO::MYSQL_ATTR_SSL_CERT] = $dbSslCert;
226
            $env['DATABASE_SSL_CERT'] = $dbSslCert;
227
        }
228
229
        if ($dbSslKey) {
230
            $params['driverOptions'][\PDO::MYSQL_ATTR_SSL_KEY] = $dbSslKey;
231
            $env['DATABASE_SSL_KEY'] = $dbSslKey;
232
        }
233
234
        if ($dbSslDontVerify) {
235
            $params['driverOptions'][\PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT] = false;
236
            $env['DATABASE_SSL_DONT_VERIFY_SERVER_CERT'] = '1';
237
        }
238
239
        if (!$input->getOption('no-check-db-connection')) {
240
            $io->note('Checking database credentials');
241
242
            $connection = DriverManager::getConnection($params, new Configuration());
243
            $connection->executeStatement('SELECT 1');
244
        }
245
246
        $env['DATABASE_URL'] = $dsn;
247
248
        return $env;
249
    }
250
251
    /**
252
     * @param array<string, string> $configuration
253
     */
254
    private function createEnvFile(InputInterface $input, SymfonyStyle $output, array $configuration): void
255
    {
256
        $output->note('Preparing .env');
257
258
        $envVars = '';
259
        $envFile = $this->projectDir . '/.env';
260
261
        foreach ($configuration as $key => $value) {
262
            $envVars .= $key . '="' . str_replace('"', '\\"', $value) . '"' . \PHP_EOL;
263
        }
264
265
        $output->text($envFile);
266
        $output->writeln('');
267
        $output->writeln($envVars);
268
269
        if ($input->isInteractive() && !$output->confirm('Check if everything is ok. Write into "' . $envFile . '"?', false)) {
270
            throw new \RuntimeException('abort');
271
        }
272
273
        $output->note('Writing into ' . $envFile);
274
275
        file_put_contents($envFile, $envVars);
276
277
        if (!$input->getOption('dump-env')) {
278
            return;
279
        }
280
281
        $dumpInput = new ArrayInput(['env' => $input->getOption('app-env')], $this->dumpEnvCommand->getDefinition());
282
        $this->dumpEnvCommand->run($dumpInput, $output);
283
    }
284
285
    private function generateJwt(InputInterface $input, OutputStyle $io): int
286
    {
287
        $jwtDir = $this->projectDir . '/config/jwt';
288
289
        if (!file_exists($jwtDir) && !mkdir($jwtDir, 0700, true) && !is_dir($jwtDir)) {
290
            throw new \RuntimeException(sprintf('Directory "%s" was not created', $jwtDir));
291
        }
292
293
        // TODO: make it regenerate the public key if only private exists
294
        if (file_exists($jwtDir . '/private.pem') && !$input->getOption('force')) {
295
            $io->note('Private/Public key already exists. Skipping');
296
297
            return self::SUCCESS;
298
        }
299
300
        if (!$input->getOption('generate-jwt-keys') && !$input->getOption('jwt-passphrase')) {
301
            return self::SUCCESS;
302
        }
303
304
        $this->jwtCertificateGenerator->generate(
305
            $jwtDir . '/private.pem',
306
            $jwtDir . '/public.pem',
307
            $input->getOption('jwt-passphrase')
308
        );
309
310
        return self::SUCCESS;
311
    }
312
313
    private function generateInstanceId(): string
314
    {
315
        $length = 32;
316
        $keySpace = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
317
318
        $str = '';
319
        $max = mb_strlen($keySpace, '8bit') - 1;
320
        for ($i = 0; $i < $length; ++$i) {
321
            $str .= $keySpace[random_int(0, $max)];
322
        }
323
324
        return $str;
325
    }
326
327
    private function getDefault(string $var, string $default): string
328
    {
329
        return (string) EnvironmentHelper::getVariable($var, $default);
330
    }
331
}
332