-
Notifications
You must be signed in to change notification settings - Fork 16
Expand file tree
/
Copy pathBootstrap.php
More file actions
550 lines (495 loc) · 22.6 KB
/
Bootstrap.php
File metadata and controls
550 lines (495 loc) · 22.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
<?php
declare(strict_types=1);
namespace MakinaCorpus\DbToolsBundle\Bridge\Standalone;
use Composer\InstalledVersions;
use MakinaCorpus\DbToolsBundle\Anonymization\AnonymizatorFactory;
use MakinaCorpus\DbToolsBundle\Anonymization\Anonymizer\AnonymizerRegistry;
use MakinaCorpus\DbToolsBundle\Anonymization\Config\Loader\ArrayLoader;
use MakinaCorpus\DbToolsBundle\Anonymization\Config\Loader\YamlLoader;
use MakinaCorpus\DbToolsBundle\Backupper\BackupperFactory;
use MakinaCorpus\DbToolsBundle\Bridge\Symfony\DependencyInjection\DbToolsConfiguration;
use MakinaCorpus\DbToolsBundle\Command\BackupCommand;
use MakinaCorpus\DbToolsBundle\Command\CheckCommand;
use MakinaCorpus\DbToolsBundle\Command\RestoreCommand;
use MakinaCorpus\DbToolsBundle\Command\StatsCommand;
use MakinaCorpus\DbToolsBundle\Command\Anonymization\AnonymizeCommand;
use MakinaCorpus\DbToolsBundle\Command\Anonymization\AnonymizerListCommand;
use MakinaCorpus\DbToolsBundle\Command\Anonymization\CleanCommand;
use MakinaCorpus\DbToolsBundle\Command\Anonymization\ConfigDumpCommand;
use MakinaCorpus\DbToolsBundle\Configuration\Configuration;
use MakinaCorpus\DbToolsBundle\Configuration\ConfigurationRegistry;
use MakinaCorpus\DbToolsBundle\Database\DatabaseSessionRegistry;
use MakinaCorpus\DbToolsBundle\Error\ConfigurationException;
use MakinaCorpus\DbToolsBundle\Restorer\RestorerFactory;
use MakinaCorpus\DbToolsBundle\Stats\StatsProviderFactory;
use MakinaCorpus\DbToolsBundle\Storage\Storage;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Symfony\Component\Config\Definition\Processor;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\LazyCommand;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Logger\ConsoleLogger;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Yaml\Yaml;
/**
* Creates Symfony console application.
*
* @internal
* @see ../../bin/db-tools.php
*/
class Bootstrap
{
/**
* Create and run Symfony console application.
*/
public static function run(): void
{
static::createApplication()->run();
}
/**
* Create Symfony console application.
*/
public static function createApplication(): Application
{
if (\class_exists(InstalledVersions::class)) {
$version = InstalledVersions::getVersion('makinacorpus/db-tools-bundle');
}
$version ??= 'cli';
$application = new Application('DbToolsBundle', $version);
$application->setCatchExceptions(true);
$application->setDefaultCommand('list');
$definition = $application->getDefinition();
$definition->addOption(new InputOption('config', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Configuration files', null));
$definition->addOption(new InputOption('env', null, InputOption::VALUE_REQUIRED, 'Environment', 'dev'));
// Hack, we need it output to have the same configuration as the application.
$input = new ArgvInput();
$output = new ConsoleOutput();
(\Closure::bind(fn () => $application->configureIO($input, $output), null, Application::class))();
// We need to parse a few arguments prior running the console
// application in order to setup commands. This is hackish but
// should work.
// @todo This does not work, fix it.
$config = $configFiles = [];
if ($input->hasOption('config')) {
foreach ((array) $input->getOption('config') as $filename) {
if ($output->isVerbose()) {
$output->writeln('Using configuration file: ' . $filename);
}
$configFiles[] = $filename;
}
}
$commands = [
'anonymization:clean' => [
fn (Context $context) => new CleanCommand(
anonymizatorFactory: $context->anonymizatorFactory,
defaultConnectionName: $context->databaseSessionRegistry->getDefaultConnectionName(),
),
'Clean DbTools left-over temporary tables',
['clean'],
],
'anonymization:config-dump' => [
fn (Context $context) => new ConfigDumpCommand(
anonymizatorFactory: $context->anonymizatorFactory,
),
'Dump anonymization configuration',
['config-dump'],
],
'anonymization:list' => [
fn (Context $context) => new AnonymizerListCommand(
anonymizerRegistry: $context->anonymizerRegistry,
),
'List all available anonymizers',
[],
],
'anonymization:run' => [
fn (Context $context) => new AnonymizeCommand(
connectionName: $context->databaseSessionRegistry->getDefaultConnectionName(),
restorerFactory: $context->restorerFactory,
backupperFactory: $context->backupperFactory,
anonymizatorFactory: $context->anonymizatorFactory,
storage: $context->storage,
),
'Anonymize given backup file or the local database',
['anonymize'],
],
'database:backup' => [
fn (Context $context) => new BackupCommand(
connectionName: $context->databaseSessionRegistry->getDefaultConnectionName(),
backupperFactory: $context->backupperFactory,
storage: $context->storage,
),
'Backup database',
['backup'],
],
'database:check' => [
fn (Context $context) => new CheckCommand(
defaultConnectionName: $context->databaseSessionRegistry->getDefaultConnectionName(),
backupperFactory: $context->backupperFactory,
restorerFactory: $context->restorerFactory,
),
'Check backup and restore binaries',
['check'],
],
'database:restore' => [
fn (Context $context) => new RestoreCommand(
connectionName: $context->databaseSessionRegistry->getDefaultConnectionName(),
restorerFactory: $context->restorerFactory,
storage: $context->storage,
),
'Restore database.',
['restore'],
],
'database:stats' => [
fn (Context $context) => new StatsCommand(
defaultConnectionName: $context->databaseSessionRegistry->getDefaultConnectionName(),
statsProviderFactory: $context->statsProviderFactory,
),
'Give some database statistics',
['stats'],
],
];
$initializer = static fn (): Context => self::bootstrap($config, $configFiles, new ConsoleLogger($output));
// All commands are wrapped into LazyCommand instances, we do not
// really care about performances here, we have really few commands
// and it's OK to initialize them all, but we need to change their
// name to shorten them.
foreach ($commands as $name => $data) {
list($callback, $description, $aliases) = $data;
$application->add(
new LazyCommand(
name: $name,
aliases: $aliases,
description: $description,
isHidden: false,
commandFactory: fn () => $callback($initializer()),
),
);
}
return $application;
}
/**
* Bootstrap components as a standalone application.
*
* @param array<string,mixed> $config
* Configuration parsed from application bootstrap using CLI options.
* This configuration must match the Symfony configuration file without
* the "db_tools" root level.
* @param array<string> $configFiles
* Additional configuration files to parse.
*/
public static function bootstrap(array $config = [], array $configFiles = [], ?LoggerInterface $logger = null): Context
{
$logger ??= new NullLogger();
$config = self::configParse($config, $configFiles, $logger);
$default = new Configuration(
backupBinary: $config['backup_binary'] ?? null,
backupExcludedTables: $config['backup_excluded_tables'] ?? null,
backupExpirationAge: $config['backup_expiration_age'] ?? null,
backupOptions: $config['backup_options'] ?? null,
backupTimeout: $config['backup_timeout'] ?? null,
restoreBinary: $config['restore_binary'] ?? null,
restoreOptions: $config['restore_options'] ?? null,
restoreTimeout: $config['restore_timeout'] ?? null,
storageDirectory: $config['storage_directory'] ?? null,
storageFilenameStrategy: $config['storage_filename_strategy'] ?? null,
);
$connections = [];
foreach (($config['connections'] ?? []) as $name => $data) {
$connections[$name] = new Configuration(
backupBinary: $data['backup_binary'] ?? null,
backupExcludedTables: $data['backup_excluded_tables'] ?? null,
backupExpirationAge: $data['backup_expiration_age'] ?? null,
backupOptions: $data['backup_options'] ?? null,
backupTimeout: $data['backup_timeout'] ?? null,
restoreBinary: $data['restore_binary'] ?? null,
restoreOptions: $data['restore_options'] ?? null,
restoreTimeout: $data['restore_timeout'] ?? null,
parent: $default,
storageDirectory: $data['storage_directory'] ?? null,
storageFilenameStrategy: $data['storage_filename_strategy'] ?? null,
url: $data['url'] ?? null,
);
}
$configRegistry = new ConfigurationRegistry($default, $connections, $config['default_connection'] ?? null);
$databaseSessionRegistry = self::createDatabaseSessionRegistry($configRegistry);
$anonymizerRegistry = self::createAnonymizerRegistry($config);
$anonymizatorFactory = new AnonymizatorFactory($databaseSessionRegistry, $anonymizerRegistry, $logger);
foreach (($config['anonymization_files'] ?? []) as $connectionName => $file) {
$anonymizatorFactory->addConfigurationLoader(new YamlLoader($file, $connectionName, $config['workdir']));
}
foreach (($config['anonymization'] ?? []) as $connectionName => $array) {
$anonymizatorFactory->addConfigurationLoader(new ArrayLoader($array, $connectionName, $config['workdir']));
}
$backupperFactory = new BackupperFactory($databaseSessionRegistry, $configRegistry, $logger);
$restorerFactory = new RestorerFactory($databaseSessionRegistry, $configRegistry, $logger);
$statsProviderFactory = new StatsProviderFactory($databaseSessionRegistry);
$storage = self::createStorage($configRegistry, $logger);
return new Context(
anonymizatorFactory: $anonymizatorFactory,
anonymizerRegistry: $anonymizerRegistry,
backupperFactory: $backupperFactory,
databaseSessionRegistry: $databaseSessionRegistry,
logger: $logger,
restorerFactory: $restorerFactory,
statsProviderFactory: $statsProviderFactory,
storage: $storage,
);
}
/**
* Gets the application root dir (path of the project's composer file).
*/
private static function getProjectDir(LoggerInterface $logger): ?string
{
// 4 level of \dirname() gets us in this project parent folder.
$candidates = [\getcwd(), \dirname(__DIR__, 4)];
foreach ($candidates as $candidate) {
$dir = $candidate;
while ($dir) {
if (\is_file($dir.'/composer.json')) {
$logger->notice('Project root directory found: {dir}', ['dir' => $dir]);
return $dir;
}
$logger->debug('Not found project directory: {dir}', ['dir' => $dir]);
$dir = \dirname($dir);
}
}
return null;
}
/**
* Parse configuration files, and environment provided configuration.
*
* @param array $config
* Overriding configuration from user input. It will overide configuration
* from given files.
* @param array $files
* Configuration files, in override order in case of conflict.
*
* @return array
* Merged proper configuration.
*/
private static function configParse(array $config, array $files, LoggerInterface $logger): array
{
$projectRoot = self::getProjectDir($logger);
$workdir = $config['workdir'] ?? $projectRoot ?? \getcwd();
// When no configuration file given, attempt to find one.
if (empty($files)) {
$configFileNames = ['db_tools.yaml', 'db_tools.yml', 'db_tools.config.yaml', 'db_tools.config.yml'];
$candidates = [];
if ($projectRoot) {
foreach ($configFileNames as $filename) {
$candidates[] = self::pathConcat($projectRoot, $filename);
}
}
if ($projectRoot !== $workdir) {
foreach ($configFileNames as $filename) {
$candidates[] = self::pathConcat($workdir, $filename);
}
}
// Will not work under Windows (and that's OK).
if ($homedir = \getenv("HOME")) {
// @todo .config folder is configurable with XDG portals?
$candidates[] = self::pathConcat($homedir, '/.config/db_tools/config.yaml');
$candidates[] = self::pathConcat($homedir, '/.config/db_tools/config.yml');
// As dot files.
foreach ($configFileNames as $filename) {
$candidates[] = self::pathConcat($homedir, '.' . $filename);
}
}
// Stop at first file found, but continue unrolling all candidates
// and emit debug output: this may help people knowing what happens
// in case of any problems when running the CLI using the -vvv
// option.
$found = false;
foreach ($candidates as $filename) {
if ($found) {
$logger->debug("Earlier configuration file found, skipping file: {file}", ['file' => $filename]);
} elseif (\file_exists($filename)) {
if (\is_readable($filename)) {
$logger->notice("Found configuration file: {file}", ['file' => $filename]);
$files[] = $filename;
$found = true;
} else {
$logger->warning("Configuration file could not be read: {file}", ['file' => $filename]);
}
} else {
$logger->debug("Configuration file does not exist: {file}", ['file' => $filename]);
}
}
}
$configs = [];
foreach ($files as $filename) {
$configs[] = self::configParseFile($filename);
}
$configs[] = $config;
$config = self::configGetEnv($config);
// Use symfony/config and our bundle configuration, which allows us
// to use it fully for validation and merge.
$configuration = new DbToolsConfiguration(true, true);
$processor = new Processor();
$config = $processor->processConfiguration($configuration, $configs);
$config = DbToolsConfiguration::finalizeConfiguration($config);
return $config;
}
/**
* Parse a single configuration file.
*/
private static function configParseFile(string $filename): array
{
if (!\file_exists($filename)) {
throw new ConfigurationException(\sprintf("%s: file does not exist.", $filename));
}
if (!\is_readable($filename)) {
throw new ConfigurationException(\sprintf("%s: file cannot be read.", $filename));
}
// 0 is not a good index for extension, this fails for false and 0.
if (!($pos = \strrpos($filename, '.'))) {
throw new ConfigurationException(\sprintf("%s: file extension cannot be guessed.", $filename));
}
$ext = \substr($filename, $pos + 1);
$config = match ($ext) {
'yml', 'yaml' => Yaml::parseFile($filename),
default => throw new ConfigurationException(\sprintf("%s: file extension '%s' is unsupported.", $filename, $ext)),
};
// Resolve all known filenames relative to this file.
// @todo Warning, this code will only work on UNIX-like filesystems.
$workdir = \rtrim($config['workdir'] ?? \dirname($filename), '/');
// Storage root directory.
if ($path = ($config['storage_directory'] ?? null)) {
$config['storage_directory'] = self::pathAbs($workdir, $path);
}
// YAML anonymizer file paths.
$yaml = $config['anonymization_files'] ?? null;
if (isset($yaml)) {
if (\is_array($yaml)) {
foreach ($yaml as $name => $path) {
$config['anonymization_files'][$name] = self::pathAbs($workdir, $path);
}
} else {
$config['anonymization_files'] = self::pathAbs($workdir, $yaml);
}
}
// Custom anonymizer paths.
foreach (($config['anonymizer_paths'] ?? []) as $index => $path) {
$config['anonymizer_paths'][$index] = self::pathAbs($workdir, $path);
}
return $config;
}
/**
* Get config variables from environment variables.
*/
private static function configGetEnv(array $config): array
{
if (!isset($config['connections'])) {
$config['connections'] = ['default' => self::getEnv('DBTOOLS_CONNECTION')];
}
if (!isset($config['backup_binary'])) {
$config['backup_binary'] = self::getEnv('DBTOOLS_BACKUP_BINARY');
}
if (!isset($config['backup_excluded_tables'])) {
$config['backup_excluded_tables'] = self::getEnv('DBTOOLS_BACKUP_EXCLUDED_TABLES');
}
if (!isset($config['backup_expiration_age'])) {
$config['backup_expiration_age'] = self::getEnv('DBTOOLS_BACKUP_EXPIRATION_AGE');
}
if (!isset($config['backup_options'])) {
$config['backup_options'] = self::getEnv('DBTOOLS_BACKUP_OPTIONS');
}
if (!isset($config['backup_timeout'])) {
$config['backup_timeout'] = self::getEnv('DBTOOLS_BACKUP_TIMEOUT');
}
if (!isset($config['default_connection'])) {
$config['default_connection'] = self::getEnv('DBTOOLS_DEFAULT_CONNECTION');
}
if (!isset($config['restore_binary'])) {
$config['restore_binary'] = self::getEnv('DBTOOLS_RESTORE_BINARY');
}
if (!isset($config['restore_options'])) {
$config['restore_options'] = self::getEnv('DBTOOLS_RESTORE_OPTIONS');
}
if (!isset($config['restore_timeout'])) {
$config['restore_timeout'] = self::getEnv('DBTOOLS_RESTORE_TIMEOUT');
}
if (!isset($config['storage_directory'])) {
$config['storage_directory'] = self::getEnv('DBTOOLS_STORAGE_DIRECTORY');
}
if (!isset($config['storage_filename_strategy'])) {
$config['storage_filename_strategy'] = self::getEnv('DBTOOLS_STORAGE_FILENAME_STRATEGY');
}
return $config;
}
private static function getEnv(string $name): string|null
{
$value = \getenv($name);
return (!$value && $value !== '0') ? null : (string) $value;
}
/**
* Create anonymizer registry and register custom code and additional packs.
*/
private static function createAnonymizerRegistry(array $config): AnonymizerRegistry
{
return new AnonymizerRegistry($config['anonymizer_paths']);
}
/**
* Create database session registry from config-given connections.
*/
private static function createDatabaseSessionRegistry(ConfigurationRegistry $configRegistry): DatabaseSessionRegistry
{
$connections = [];
foreach ($configRegistry->getConnectionConfigAll() as $name => $config) {
\assert($config instanceof Configuration);
$connections[$name] = $config->getUrl() ?? throw new ConfigurationException(\sprintf('Connection "%s" is missing the "url" option.', $name));
}
// Do not crash on initialization, it will crash later when a connection
// will be request instead: this allows commands that don't act on
// database (such as anonymizer list) to work even if not configured.
return new StandaloneDatabaseSessionRegistry($connections, $configRegistry->getDefaultConnection());
}
/**
* Create storage.
*/
private static function createStorage(ConfigurationRegistry $configRegistry, LoggerInterface $logger): Storage
{
$rootdir = $configRegistry->getDefaultConfig()->getStorageDirectory();
if (!\is_dir($rootdir)) {
if (\file_exists($rootdir)) {
throw new ConfigurationException(\sprintf("Storage root folder is a regular file instead of a directory: %s", $rootdir));
}
$logger->notice("Storage root folder does not exists: {dir}", ['dir' => $rootdir]);
} else {
$logger->notice("Found storage root folder: {dir}", ['dir' => $rootdir]);
}
return new Storage($configRegistry);
}
/**
* Concat and make absolute using given root if relative.
*/
private static function pathAbs(string $root, string ...$pieces): string
{
$path = self::pathConcat(...$pieces);
if (\str_starts_with($path, '/')) {
return $path;
}
return self::pathConcat($root, $path);
}
/**
* Concat all path segment while cleaning a bit given input.
*/
private static function pathConcat(string ...$pieces): string
{
$first = true;
foreach ($pieces as $index => $piece) {
if ($first) {
$first = false;
$pieces[$index] = \rtrim($piece, '/\\');
} else {
if (\str_starts_with($piece, './')) {
$piece = \substr($piece, 2);
}
$pieces[$index] = \trim($piece, '/\\');
}
}
return \implode(DIRECTORY_SEPARATOR, $pieces);
}
}