Completed
Push — master ( 5b059b...7ec67f )
by Gaetano
07:17
created

executeMigrationInSeparateProcess()   B

Complexity

Conditions 7
Paths 8

Size

Total Lines 48

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 56

Importance

Changes 0
Metric Value
dl 0
loc 48
ccs 0
cts 29
cp 0
rs 8.2012
c 0
b 0
f 0
cc 7
nc 8
nop 5
crap 56
1
<?php
2
3
namespace Kaliop\eZMigrationBundle\Command;
4
5
use Kaliop\eZMigrationBundle\Core\MigrationService;
6
use Symfony\Component\Console\Input\ArrayInput;
7
use Symfony\Component\Console\Input\InputInterface;
8
use Symfony\Component\Console\Output\OutputInterface;
9
use Symfony\Component\Console\Input\InputOption;
10
use Kaliop\eZMigrationBundle\API\Value\MigrationDefinition;
11
use Kaliop\eZMigrationBundle\API\Value\Migration;
12
use Kaliop\eZMigrationBundle\API\Exception\AfterMigrationExecutionException;
13
use Symfony\Component\Process\ProcessBuilder;
14
use Symfony\Component\Process\PhpExecutableFinder;
15
use Symfony\Component\Console\Helper\Table;
16
use Symfony\Component\Console\Question\ConfirmationQuestion;
17
18
/**
19
 * Command to execute the available migration definitions.
20
 */
21
class MigrateCommand extends AbstractCommand
22
{
23
    // in between QUIET and NORMAL
24
    const VERBOSITY_CHILD = 0.5;
25
    /** @var OutputInterface $output */
26
    protected $output;
27
    protected $verbosity = OutputInterface::VERBOSITY_NORMAL;
28
29
    const COMMAND_NAME = 'kaliop:migration:migrate';
30
31
    /**
32
     * Set up the command.
33
     *
34
     * Define the name, options and help text.
35
     */
36 76
    protected function configure()
37
    {
38 76
        parent::configure();
39
40
        $this
41 76
            ->setName(self::COMMAND_NAME)
42 76
            ->setAliases(array('kaliop:migration:update'))
43 76
            ->setDescription('Execute available migration definitions.')
44 76
            // nb: when adding options, remember to forward them to sub-commands executed in 'separate-process' mode
45
            ->addOption('admin-login', 'a', InputOption::VALUE_REQUIRED, "Login of admin account used whenever elevated privileges are needed (user id 14 used by default)")
46 76
            ->addOption('clear-cache', 'c', InputOption::VALUE_NONE, "Clear the cache after the command finishes")
47 76
            ->addOption('default-language', 'l', InputOption::VALUE_REQUIRED, "Default language code that will be used if no language is provided in migration steps")
48 76
            ->addOption('force', 'f', InputOption::VALUE_NONE, "Force (re)execution of migrations already DONE, SKIPPED or FAILED. Use with great care!")
49 76
            ->addOption('ignore-failures', 'i', InputOption::VALUE_NONE, "Keep executing migrations even if one fails")
50 76
            ->addOption('no-interaction', 'n', InputOption::VALUE_NONE, "Do not ask any interactive question")
51 76
            ->addOption('no-transactions', 'u', InputOption::VALUE_NONE, "Do not use a repository transaction to wrap each migration. Unsafe, but needed for legacy slot handlers")
52 76
            ->addOption('path', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, "The directory or file to load the migration definitions from")
53 76
            ->addOption('separate-process', 'p', InputOption::VALUE_NONE, "Use a separate php process to run each migration. Safe if your migration leak memory. A tad slower")
54 76
            ->addOption('child', null, InputOption::VALUE_NONE, "*DO NOT USE* Internal option for when forking separate processes")
55 76
            ->setHelp(<<<EOT
56
The <info>kaliop:migration:migrate</info> command loads and executes migrations:
57
58
    <info>./ezpublish/console kaliop:migration:migrate</info>
59
60
You can optionally specify the path to migration definitions with <info>--path</info>:
61
62
    <info>./ezpublish/console kaliop:migrations:migrate --path=/path/to/bundle/version_directory --path=/path/to/bundle/version_directory/single_migration_file</info>
63
EOT
64 76
            );
65
    }
66
67
    /**
68
     * Execute the command.
69
     *
70
     * @param InputInterface $input
71
     * @param OutputInterface $output
72
     * @return null|int null or 0 if everything went fine, or an error code
73
     */
74
    protected function execute(InputInterface $input, OutputInterface $output)
75
    {
76
        $start = microtime(true);
77
78
        $this->setOutput($output);
79
        $this->setVerbosity($output->getVerbosity());
80
81
        if ($input->getOption('child')) {
82
            $this->setVerbosity(self::VERBOSITY_CHILD);
83
        }
84
85
        $this->getContainer()->get('ez_migration_bundle.step_executed_listener.tracing')->setOutput($output);
86
87
        $migrationService = $this->getMigrationService();
88
89
        $force = $input->getOption('force');
90
91
        $toExecute = $this->buildMigrationsList($input->getOption('path'), $migrationService, $force);
92
93
        if (!count($toExecute)) {
94
            $output->writeln('<info>No migrations to execute</info>');
95
            return 0;
96
        }
97
98
        $this->printMigrationsList($toExecute, $input, $output);
99
100
        // ask user for confirmation to make changes
101
        if (!$this->askForConfirmation($input, $output)) {
102
            return 0;
103
        }
104
105 View Code Duplication
        if ($input->getOption('separate-process')) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
106
            $builder = new ProcessBuilder();
107
            $executableFinder = new PhpExecutableFinder();
108
            if (false !== $php = $executableFinder->find()) {
109
                $builder->setPrefix($php);
110
            }
111
            $builderArgs = $this->createChildProcessArgs($input);
112
        }
113
114
        $executed = 0;
115
        $failed = 0;
116
        $skipped = 0;
117
118
        /** @var MigrationDefinition $migrationDefinition */
119
        foreach ($toExecute as $name => $migrationDefinition) {
120
121
            // let's skip migrations that we know are invalid - user was warned and he decided to proceed anyway
122
            if ($migrationDefinition->status == MigrationDefinition::STATUS_INVALID) {
123
                $output->writeln("<comment>Skipping $name</comment>\n");
124
                $skipped++;
125
                continue;
126
            }
127
128
            $this->writeln("<info>Processing $name</info>");
129
130
            if ($input->getOption('separate-process')) {
131
132
                try {
133
                    $this->executeMigrationInSeparateProcess($migrationDefinition, $migrationService, $builder, $builderArgs);
0 ignored issues
show
Bug introduced by
The variable $builder does not seem to be defined for all execution paths leading up to this point.

If you define a variable conditionally, it can happen that it is not defined for all execution paths.

Let’s take a look at an example:

function myFunction($a) {
    switch ($a) {
        case 'foo':
            $x = 1;
            break;

        case 'bar':
            $x = 2;
            break;
    }

    // $x is potentially undefined here.
    echo $x;
}

In the above example, the variable $x is defined if you pass “foo” or “bar” as argument for $a. However, since the switch statement has no default case statement, if you pass any other value, the variable $x would be undefined.

Available Fixes

  1. Check for existence of the variable explicitly:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        if (isset($x)) { // Make sure it's always set.
            echo $x;
        }
    }
    
  2. Define a default value for the variable:

    function myFunction($a) {
        $x = ''; // Set a default which gets overridden for certain paths.
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        echo $x;
    }
    
  3. Add a value for the missing path:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
    
            // We add support for the missing case.
            default:
                $x = '';
                break;
        }
    
        echo $x;
    }
    
Loading history...
Bug introduced by
The variable $builderArgs does not seem to be defined for all execution paths leading up to this point.

If you define a variable conditionally, it can happen that it is not defined for all execution paths.

Let’s take a look at an example:

function myFunction($a) {
    switch ($a) {
        case 'foo':
            $x = 1;
            break;

        case 'bar':
            $x = 2;
            break;
    }

    // $x is potentially undefined here.
    echo $x;
}

In the above example, the variable $x is defined if you pass “foo” or “bar” as argument for $a. However, since the switch statement has no default case statement, if you pass any other value, the variable $x would be undefined.

Available Fixes

  1. Check for existence of the variable explicitly:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        if (isset($x)) { // Make sure it's always set.
            echo $x;
        }
    }
    
  2. Define a default value for the variable:

    function myFunction($a) {
        $x = ''; // Set a default which gets overridden for certain paths.
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        echo $x;
    }
    
  3. Add a value for the missing path:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
    
            // We add support for the missing case.
            default:
                $x = '';
                break;
        }
    
        echo $x;
    }
    
Loading history...
134
135
                    $executed++;
136
                } catch (\Exception $e) {
137
                    if ($input->getOption('ignore-failures')) {
138
                        $output->writeln("\n<error>Migration failed! Reason: " . $e->getMessage() . "</error>\n");
139
                        $failed++;
140
                        continue;
141
                    }
142
                    if ($e instanceof AfterMigrationExecutionException) {
143
                        $output->writeln("\n<error>Failure after migration end! Reason: " . $e->getMessage() . "</error>");
144
                    } else {
145
                        $output->writeln("\n<error>Migration aborted! Reason: " . $e->getMessage() . "</error>");
146
                    }
147
                    return 1;
148
                }
149
150
            } else {
151
152
                try {
153
                    $this->executeMigrationInProcess($migrationDefinition, $force, $migrationService, $input);
154
155
                    $executed++;
156
                } catch (\Exception $e) {
157
                    if ($input->getOption('ignore-failures')) {
158
                        $output->writeln("\n<error>Migration failed! Reason: " . $e->getMessage() . "</error>\n");
159
                        $failed++;
160
                        continue;
161
                    }
162
                    $output->writeln("\n<error>Migration aborted! Reason: " . $e->getMessage() . "</error>");
163
                    return 1;
164
                }
165
166
            }
167
        }
168
169 View Code Duplication
        if ($input->getOption('clear-cache')) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
170
            $command = $this->getApplication()->find('cache:clear');
171
            $inputArray = new ArrayInput(array('command' => 'cache:clear'));
172
            $command->run($inputArray, $output);
173
        }
174
175
        $time = microtime(true) - $start;
176
        $this->writeln("Executed $executed migrations, failed $failed, skipped $skipped");
177
        if ($input->getOption('separate-process')) {
178
            // in case of using subprocesses, we can not measure max memory used
179
            $this->writeln("Time taken: ".sprintf('%.2f', $time)." secs");
180
        } else {
181
            $this->writeln("Time taken: ".sprintf('%.2f', $time)." secs, memory: ".sprintf('%.2f', (memory_get_peak_usage(true) / 1000000)). ' MB');
182
        }
183
    }
184
185
    protected function executeMigrationInProcess($migrationDefinition, $force, $migrationService, $input)
186
    {
187
        $migrationService->executeMigration(
188
            $migrationDefinition,
189
            !$input->getOption('no-transactions'),
190
            $input->getOption('default-language'),
191
            $input->getOption('admin-login'),
192
            $force
193
        );
194
    }
195
196
    protected function executeMigrationInSeparateProcess($migrationDefinition, $migrationService, $builder, $builderArgs, $feedback = true)
197
    {
198
        $process = $builder
199
            ->setArguments(array_merge($builderArgs, array('--path=' . $migrationDefinition->path)))
200
            ->getProcess();
201
202
        if ($feedback) {
203
            $this->writeln('<info>Executing: ' . $process->getCommandLine() . '</info>', OutputInterface::VERBOSITY_VERBOSE);
204
        }
205
206
        // allow long migrations processes by default
207
        $process->setTimeout(86400);
208
        // and give immediate feedback to the user
209
        $process->run(
210
            $feedback ?
211
                function($type, $buffer) {
212
                    echo $buffer;
213
                }
214
                :
215
                function($type, $buffer) {
0 ignored issues
show
Unused Code introduced by
The parameter $type is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
Unused Code introduced by
The parameter $buffer is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
216
                }
217
        );
218
219
        if (!$process->isSuccessful()) {
220
            throw new \Exception($process->getErrorOutput());
221
        }
222
223
        // There are cases where the separate process dies halfway but does not return a non-zero code.
224
        // That's why we double-check here if the migration is still tagged as 'started'...
225
        /** @var Migration $migration */
226
        $migration = $migrationService->getMigration($migrationDefinition->name);
227
228
        if (!$migration) {
229
            // q: shall we add the migration to the db as failed? In doubt, we let it become a ghost, disappeared without a trace...
230
            throw new \Exception("After the separate process charged to execute the migration finished, the migration can not be found in the database any more.");
231
        } else if ($migration->status == Migration::STATUS_STARTED) {
232
            $errorMsg = "The separate process charged to execute the migration left it in 'started' state. Most likely it died halfway through execution.";
233
            $migrationService->endMigration(New Migration(
234
                $migration->name,
235
                $migration->md5,
236
                $migration->path,
237
                $migration->executionDate,
238
                Migration::STATUS_FAILED,
239
                ($migration->executionError != '' ? ($errorMsg . ' ' . $migration->executionError) : $errorMsg)
240
            ));
241
            throw new \Exception($errorMsg);
242
        }
243
    }
244
245
    /**
246
     * @param string[] $paths
247
     * @param MigrationService $migrationService
248
     * @param bool $force when true, look not only for TODO migrations, but also DONE, SKIPPED, FAILED ones (we still omit STARTED and SUSPENDED ones)
249
     * @return MigrationDefinition[]
250
     *
251
     * @todo this does not scale well with many definitions or migrations
252
     */
253
    protected function buildMigrationsList($paths, $migrationService, $force = false)
254
    {
255
        $migrationDefinitions = $migrationService->getMigrationsDefinitions($paths);
256
        $migrations = $migrationService->getMigrations();
257
258
        $allowedStatuses = array(Migration::STATUS_TODO);
259 View Code Duplication
        if ($force) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
260
            $allowedStatuses = array_merge($allowedStatuses, array(Migration::STATUS_DONE, Migration::STATUS_FAILED, Migration::STATUS_SKIPPED));
261
        }
262
263
        // filter away all migrations except 'to do' ones
264
        $toExecute = array();
265
        foreach ($migrationDefinitions as $name => $migrationDefinition) {
266 View Code Duplication
            if (!isset($migrations[$name]) || (($migration = $migrations[$name]) && in_array($migration->status, $allowedStatuses))) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
267
                $toExecute[$name] = $migrationService->parseMigrationDefinition($migrationDefinition);
268
            }
269
        }
270
271
        // if user wants to execute 'all' migrations: look for some which are registered in the database even if not
272
        // found by the loader
273 View Code Duplication
        if (empty($paths)) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
274
            foreach ($migrations as $migration) {
275
                if (in_array($migration->status, $allowedStatuses) && !isset($toExecute[$migration->name])) {
276
                    $migrationDefinitions = $migrationService->getMigrationsDefinitions(array($migration->path));
277
                    if (count($migrationDefinitions)) {
278
                        $migrationDefinition = reset($migrationDefinitions);
279
                        $toExecute[$migration->name] = $migrationService->parseMigrationDefinition($migrationDefinition);
280
                    } else {
281
                        // q: shall we raise a warning here ?
282
                    }
283
                }
284
            }
285
        }
286
287
        ksort($toExecute);
288
289
        return $toExecute;
290
    }
291
292
    /**
293
     * @param MigrationDefinition[] $toExecute
294
     * @param InputInterface $input
295
     * @param OutputInterface $output
296
     *
297
     * @todo use a more compact output when there are *many* migrations
298
     */
299
    protected function printMigrationsList($toExecute, InputInterface $input, OutputInterface $output)
300
    {
301
        $data = array();
302
        $i = 1;
303
        foreach ($toExecute as $name => $migrationDefinition) {
304
            $notes = '';
305
            if ($migrationDefinition->status != MigrationDefinition::STATUS_PARSED) {
306
                $notes = '<error>' . $migrationDefinition->parsingError . '</error>';
307
            }
308
            $data[] = array(
309
                $i++,
310
                $name,
311
                $notes
312
            );
313
        }
314
315
        if (!$input->getOption('child')) {
316
            $table = new Table($output);
317
            $table
318
                ->setHeaders(array('#', 'Migration', 'Notes'))
319
                ->setRows($data);
320
            $table->render();
321
        }
322
323
        $this->writeln('');
324
    }
325
326
    protected function askForConfirmation(InputInterface $input, OutputInterface $output, $nonIteractiveOutput = "=============================================\n")
327
    {
328
        if ($input->isInteractive() && !$input->getOption('no-interaction')) {
329
            $dialog = $this->getHelperSet()->get('question');
330
            if (!$dialog->ask(
331
                $input,
332
                $output,
333
                new ConfirmationQuestion('<question>Careful, the database will be modified. Do you want to continue Y/N ?</question>', false)
334
            )
335
            ) {
336
                $output->writeln('<error>Migration execution cancelled!</error>');
337
                return 0;
338
            }
339
        } else {
340
            if ($nonIteractiveOutput != '') {
341
                $this->writeln("$nonIteractiveOutput");
342
            }
343
        }
344
345
        return 1;
346
    }
347
348
    /**
349
     * Small tricks to allow us to lower verbosity between NORMAL and QUIET and have a decent writeln API, even with old SF versions
350
     * @param $message
351
     * @param int $verbosity
352
     */
353
    protected function writeln($message, $verbosity = OutputInterface::VERBOSITY_NORMAL)
354
    {
355
        if ($this->verbosity >= $verbosity) {
356
            $this->output->writeln($message);
357
        }
358
    }
359
360
    protected function setOutput(OutputInterface $output)
361
    {
362
        $this->output = $output;
363
    }
364
365
    protected function setVerbosity($verbosity)
366
    {
367
        $this->verbosity = $verbosity;
368
    }
369
370
    /**
371
     * Returns the command-line arguments needed to execute a migration in a separate subprocess (omitting 'path')
372
     * @param InputInterface $input
373
     * @return array
374
     */
375
    protected function createChildProcessArgs(InputInterface $input)
376
    {
377
        $kernel = $this->getContainer()->get('kernel');
378
379
        // mandatory args and options
380
        $builderArgs = array(
381
            $_SERVER['argv'][0], // sf console
382
            self::COMMAND_NAME, // name of sf command. Can we get it from the Application instead of hardcoding?
383
            '--env=' . $kernel->getEnvironment(), // sf env
384
            '--child'
385
        );
386
        // sf/ez env options
387
        if (!$kernel->isDebug()) {
388
            $builderArgs[] = '--no-debug';
389
        }
390
        if ($input->getOption('siteaccess')) {
391
            $builderArgs[]='--siteaccess='.$input->getOption('siteaccess');
392
        }
393
        // 'optional' options
394
        // note: options 'clear-cache', 'ignore-failures', 'no-interaction', 'path' and 'separate-process' we never propagate
395
        if ($input->getOption('admin-login')) {
396
            $builderArgs[] = '--admin-login=' . $input->getOption('admin-login');
397
        }
398
        if ($input->getOption('default-language')) {
399
            $builderArgs[] = '--default-language=' . $input->getOption('default-language');
400
        }
401
        if ($input->getOption('force')) {
402
            $builderArgs[] = '--force';
403
        }
404
        if ($input->getOption('no-transactions')) {
405
            $builderArgs[] = '--no-transactions';
406
        }
407
408
        return $builderArgs;
409
    }
410
}
411