AbstractDatabase::getPhp()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
c 1
b 0
f 0
nc 1
nop 0
dl 0
loc 3
ccs 0
cts 2
cp 0
crap 2
rs 10
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Ecodev\Felix\Service;
6
7
use Exception;
8
9
/**
10
 * Tool to reload the entire local database from remote database for a given site.
11
 *
12
 * Requirements:
13
 *
14
 * - ssh access to remote server (via ~/.ssh/config)
15
 * - both local and remote sites must be accessible via: /sites/MY_SITE
16
 */
17
abstract class AbstractDatabase
18
{
19
    /**
20
     * This is lazy architecture, and we should instead convert the whole class
21
     * into instantiable service with configuration in constructor, and default
22
     * factory that get the PHP version from config.
23
     */
24
    protected static function getPhp(): string
25
    {
26
        return 'php8.2';
27
    }
28
29
    /**
30
     * Dump data from database on $remote server.
31
     */
32
    private static function dumpDataRemotely(string $remote, string $dumpFile): void
33
    {
34
        $php = static::getPhp();
35
        $sshCmd = <<<STRING
36
            ssh $remote "cd /sites/$remote/ && $php bin/dump-data.php $dumpFile"
37
            STRING;
38
39
        echo "dumping data $dumpFile on $remote...\n";
40
        self::executeLocalCommand($sshCmd);
41
    }
42
43
    /**
44
     * Dump data from database.
45
     */
46
    final public static function dumpData(string $dumpFile): void
47
    {
48
        $mariadbArgs = self::getMariadbArgs();
49
50
        echo "dumping $dumpFile...\n";
51
        $dumpCmd = "mariadb-dump -v $mariadbArgs | LC_CTYPE=C LANG=C sed 's/DEFINER=[^*]*\\*/\\*/g' | gzip > $dumpFile";
52
        self::executeLocalCommand($dumpCmd);
53
    }
54
55
    /**
56
     * Copy a file from $remote.
57
     */
58
    private static function copyFile(string $remote, string $dumpFile): void
59
    {
60
        $copyCmd = <<<STRING
61
            rsync -avz --progress $remote:$dumpFile $dumpFile
62
            STRING;
63
64
        echo "copying dump to $dumpFile ...\n";
65
        self::executeLocalCommand($copyCmd);
66
    }
67
68
    /**
69
     * Load SQL dump in local database.
70
     */
71
    final public static function loadData(string $dumpFile): void
72
    {
73
        $mariadbArgs = self::getMariadbArgs();
74
        $dumpFile = self::absolutePath($dumpFile);
75
76
        self::confirm('DROP the entire database `' . self::getDatabaseName() . "` and load dump $dumpFile ?");
77
78
        echo "loading dump $dumpFile...\n";
79
        $database = self::getDatabaseName();
80
81
        // We close the connection to DB here to avoid a timeout when loading the backup
82
        // It will be re-opened automatically
83
        echo "closing connection to DB\n";
84
        _em()->getConnection()->close();
85
86
        self::executeLocalCommand(PHP_BINARY . ' ./bin/doctrine orm:schema-tool:drop --ansi --full-database --force');
87
        self::executeLocalCommand("gunzip -c \"$dumpFile\" | LC_CTYPE=C LANG=C sed 's/ALTER DATABASE `[^`]*`/ALTER DATABASE `$database`/g' | mariadb $mariadbArgs");
88
        self::executeLocalCommand(PHP_BINARY . ' ./bin/doctrine migrations:migrate --ansi --no-interaction');
89
        static::loadTriggers();
90
        static::loadTestUsers();
91
    }
92
93
    protected static function getDatabaseName(): string
94
    {
95
        /** @var array<string,string> $dbConfig */
96
        $dbConfig = _em()->getConnection()->getParams();
97
98
        return $dbConfig['dbname'];
99
    }
100
101
    private static function getMariadbArgs(): string
102
    {
103
        /** @var array<string,int|string> $dbConfig */
104
        $dbConfig = _em()->getConnection()->getParams();
105
106
        $host = $dbConfig['host'] ?? 'localhost';
107
        $username = $dbConfig['user'];
108
        $database = $dbConfig['dbname'];
109
        $password = $dbConfig['password'];
110
        $port = $dbConfig['port'] ?? null;
111
112
        if ($port) {
113
            $port = "--protocol tcp --port=$port";
114
        } else {
115
            $port = '--protocol socket';
116
        }
117
118
        // It's possible to have no password at all
119
        $password = $password ? '-p' . $password : '';
120
121
        return "--user=$username $password --host=$host $port $database";
122
    }
123
124
    final public static function loadRemoteData(string $remote): void
125
    {
126
        $dumpFile = "/tmp/$remote." . exec('whoami') . '.backup.sql.gz';
127
        self::dumpDataRemotely($remote, $dumpFile);
128
        self::copyFile($remote, $dumpFile);
129
        self::loadData($dumpFile);
130
131
        echo "database updated\n";
132
    }
133
134
    /**
135
     * Execute a shell command and throw exception if fails.
136
     */
137
    final public static function executeLocalCommand(string $command): void
138
    {
139
        // This allows to specify an application environnement even for commands that are not ours, such as Doctrine one.
140
        // Thus, this allows us to correctly load test data in a separate test database for OKpilot.
141
        if (defined('APPLICATION_ENV')) {
142
            $env = 'APPLICATION_ENV=' . APPLICATION_ENV;
0 ignored issues
show
Bug introduced by
The constant Ecodev\Felix\Service\APPLICATION_ENV was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
143
        } else {
144
            $env = '';
145
        }
146
147
        $return_var = null;
148
        $fullCommand = "$env $command 2>&1";
149
        passthru($fullCommand, $return_var);
150
        if ($return_var) {
151
            throw new Exception('FAILED executing: ' . $command);
152
        }
153
    }
154
155
    /**
156
     * Load test data.
157
     */
158
    public static function loadTestData(): void
159
    {
160
        self::confirm('DROP the entire database `' . self::getDatabaseName() . '` and load test data ?');
161
162
        self::executeLocalCommand(PHP_BINARY . ' ./bin/doctrine orm:schema-tool:drop --ansi --full-database --force');
163
        self::executeLocalCommand(PHP_BINARY . ' ./bin/doctrine migrations:migrate --ansi --no-interaction');
164
        static::loadTriggers();
165
        static::loadTestUsers();
166
        self::importFile('tests/data/fixture.sql');
167
    }
168
169
    /**
170
     * Load triggers.
171
     */
172
    public static function loadTriggers(): void
173
    {
174
        self::importFile('data/triggers.sql');
175
    }
176
177
    /**
178
     * Load test users.
179
     */
180
    protected static function loadTestUsers(): void
181
    {
182
        self::importFile('tests/data/users.sql');
183
    }
184
185
    /**
186
     * Import a SQL file into DB.
187
     *
188
     * This use mariadb command, instead of DBAL methods, to allow to see errors if any, and
189
     * also because it seems trigger creation do not work with DBAL for some unclear reasons.
190
     */
191
    final public static function importFile(string $file): void
192
    {
193
        $file = self::absolutePath($file);
194
        $mariadbArgs = self::getMariadbArgs();
195
196
        echo 'importing ' . $file . "\n";
197
198
        $importCommand = "echo 'SET NAMES utf8mb4;' | cat - $file | mariadb $mariadbArgs";
199
200
        self::executeLocalCommand($importCommand);
201
    }
202
203
    private static function absolutePath(string $file): string
204
    {
205
        $absolutePath = realpath($file);
206
        if ($absolutePath === false) {
207
            throw new Exception('Cannot find absolute path for file: ' . $file);
208
        }
209
210
        if (!is_readable($absolutePath)) {
211
            throw new Exception("Cannot read dump file \"$absolutePath\"");
212
        }
213
214
        return $absolutePath;
215
    }
216
217
    /**
218
     * Ask confirmation to user (y/n), unless if we are really sure that we are in local development,
219
     * or if command argument `--no-interaction` is given.
220
     *
221
     * If the user does not confirm, the program will exit immediately.
222
     */
223
    public static function confirm(string $question): void
224
    {
225
        global $argv;
226
        $path = getcwd() ?: '';
227
        if (str_ends_with($path, '.lan') || in_array('--no-interaction', $argv, true)) {
228
            return;
229
        }
230
231
        echo <<<STRING
232
233
            ⚠️    ⚠️    ⚠️    ⚠️    ⚠️    ⚠️    ⚠️    ⚠️    ⚠️    ⚠️    ⚠️    ⚠️ 
234
235
            It looks like this is a\033[01;31m PRODUCTION SITE\033[0m: $path
236
237
            $question (y)es (n)o
238
239
            ⚠️    ⚠️    ⚠️    ⚠️    ⚠️    ⚠️    ⚠️    ⚠️    ⚠️    ⚠️    ⚠️    ⚠️ 
240
241
            STRING;
242
243
        $confirm = readline() ?: '';
244
        if (!preg_match('/^y(es)?$/', $confirm)) {
245
            exit(1);
246
        }
247
    }
248
}
249