LGV_TZ_Lookup 1.4.2
Time zone lookup for PHP applications and HTTP clients
Loading...
Searching...
No Matches
deploy.php
Go to the documentation of this file.
1<?php
2/***************************************************************************************************************************/
3/**
4 © Copyright 2023-2026, [Little Green Viper Software Development LLC](https://littlegreenviper.com)
5
6 LICENSE:
7
8 MIT License
9
10 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation
11 files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy,
12 modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
13 Software is furnished to do so, subject to the following conditions:
14
15 The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
16
17 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
18 OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19 IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
20 CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21
22 [Little Green Viper Software Development LLC](https://littlegreenviper.com)
23*/
24/***************************************************************************************************************************/
25/**
26 \file
27 \brief These tests check deployment ownership and recovery against an isolated MySQL or PostgreSQL database.
28 Run with --driver=mysql or --driver=pgsql, using the same LGV_TZ_TEST_* settings as tests/databases.php.
29 No boundaries are downloaded. Add --real-composer to install the local fixture through Composer as well.
30 The test database and working directories are removed afterward.
31*/
32declare(strict_types=1);
33require dirname(__DIR__).'/tools/deploy.php';
34$options = getopt('', ['driver:', 'real-composer']);
35$driver = $options['driver'] ?? 'mysql';
36if (!in_array($driver, ['mysql', 'pgsql'], true)) { throw new InvalidArgumentException('Choose mysql or pgsql.'); }
38
39/***************************************************************************************************************************/
40/** \brief This checks an installer result. \throws RuntimeException if it differs. */
41function deployCheck($expected, $actual, string $label): void {
42 global $checks;
43 ++$checks;
44 if ($expected !== $actual) { throw new RuntimeException($label.' failed.'); }
45}
46
47// Keep the procedural driver in the source browser; Doxygen misreads its catch/finally clauses as declarations.
48/** \cond */
49// The web directory can be a nested served directory, so its URL prefix must survive deployment reporting.
50foreach ([
51 ['https://example.com', 'timezone', 'https://example.com/timezone/'],
52 ['https://example.com/', 'timezones', 'https://example.com/timezones/'],
53 ['https://recovrr.org/recovrr/timezones', 'timezone', 'https://recovrr.org/recovrr/timezones/timezone/'],
54 ['https://recovrr.org/recovrr/timezones/', 'timezone', 'https://recovrr.org/recovrr/timezones/timezone/'],
55 ['http://localhost:8080/services', 'timezone', 'http://localhost:8080/services/timezone/'],
56 ['https://example.com/a%20directory/', 'tz_service-2', 'https://example.com/a%20directory/tz_service-2/'],
57] as [$webUrl, $endpoint, $expected]) {
58 deployCheck($expected, deployServiceUrl($webUrl, $endpoint), 'Preserve the public directory URL and append one service subdirectory');
59}
60foreach (['', '/timezone', 'recovrr.org/recovrr', 'ftp://example.com', 'https://user:password@example.com',
61 'https://example.com/?secret=old', 'https://example.com/#section', 'https://example.com/a b',
62 'https://example.com/path\\other'] as $webUrl) {
63 try { deployServiceUrl($webUrl, 'timezone'); throw new RuntimeException('Expected web URL refusal.'); }
64 catch (InvalidArgumentException $error) { deployCheck(true, str_contains($error->getMessage(), 'full http://'), 'Reject an ambiguous or unsuitable web directory URL'); }
65}
66
67$name = 'lgv_tz_deploy_test_'.bin2hex(random_bytes(8));
68$settings = ['driver' => $driver, 'host' => getenv('LGV_TZ_TEST_HOST') ?: 'localhost',
69 'port' => (int)(getenv('LGV_TZ_TEST_PORT') ?: ($driver === 'pgsql' ? 5432 : 3306)),
70 'database' => $name, 'user' => getenv('LGV_TZ_TEST_USER') ?: ($driver === 'pgsql' ? (getenv('USER') ?: get_current_user()) : 'root'),
71 'password' => getenv('LGV_TZ_TEST_PASSWORD') ?: '', 'admin_database' => getenv('LGV_TZ_TEST_ADMIN_DATABASE') ?: 'postgres'];
73$admin->exec('CREATE DATABASE '.deployIdentifier($name, $driver));
74$work = sys_get_temp_dir().'/lgv-tz-deploy-test-'.bin2hex(random_bytes(8));
75mkdir($work, 0700);
76try {
77 // Fresh installations can use directories prepared by the operator. Publication and rollback preserve ownership.
78 foreach (['before-publication', 'interrupted', 'interrupted-before-stage', 'interrupted-both',
79 'published', 'new-file', 'public-new-file', 'changed-identity'] as $scenario) {
80 $caseWork = $work.'/empty-'.$scenario;
81 mkdir($caseWork, 0700);
82 $privateTarget = $caseWork.'/private'; $publicTarget = $caseWork.'/public';
83 mkdir($privateTarget, 0750); mkdir($publicTarget, 0700);
84 $empty = deployEmptyDirectories(['private' => $privateTarget, 'public' => $publicTarget]);
85 deployCheck(['private', 'public'], array_keys($empty), 'Recognize pre-existing empty destinations');
86 $token = bin2hex(random_bytes(32));
87 $directoryState = ['private' => $privateTarget, 'public' => $publicTarget,
88 'private_stage' => $caseWork.'/private-stage', 'public_stage' => $caseWork.'/public-stage',
89 'token' => $token, 'published' => false, 'empty_directories' => $empty];
90 foreach (['private_stage', 'public_stage'] as $key) {
91 mkdir($directoryState[$key], 0700);
92 file_put_contents($directoryState[$key].'/.lgv-tz-install-owner', $token);
93 file_put_contents($directoryState[$key].'/fixture.txt', 'installed file');
94 }
95 deploySave($caseWork, $directoryState);
96 $privatePermissions = fileperms($privateTarget) & 0777;
97 $privateOwner = fileowner($privateTarget); $privateGroup = filegroup($privateTarget);
98 if ($scenario === 'interrupted') {
99 // Stop after the private directory switched, while the original public directory is still in place.
100 rename($privateTarget, $empty['private']['backup']);
101 rename($directoryState['private_stage'], $privateTarget);
102 } elseif ($scenario === 'interrupted-before-stage') {
103 rename($privateTarget, $empty['private']['backup']);
104 } elseif ($scenario === 'interrupted-both') {
105 deployPublishDirectories($directoryState);
106 } elseif ($scenario === 'published') {
107 deployPublishDirectories($directoryState);
108 $directoryState['published'] = true;
109 deploySave($caseWork, $directoryState);
110 } elseif ($scenario === 'new-file' || $scenario === 'public-new-file') {
111 $noteTarget = $scenario === 'new-file' ? $privateTarget : $publicTarget;
112 file_put_contents($noteTarget.'/.operator-note', 'keep this new file');
113 try { deployPublishDirectories($directoryState); throw new RuntimeException('Expected changed-directory refusal.'); }
114 catch (RuntimeException $error) { deployCheck(true, str_contains($error->getMessage(), 'changed during'), 'Refuse publication after a hidden file appears'); }
115 } elseif ($scenario === 'changed-identity') {
116 rename($privateTarget, $caseWork.'/original-private');
117 mkdir($privateTarget, 0700);
118 try { deployPublishDirectories($directoryState); throw new RuntimeException('Expected replaced-directory refusal.'); }
119 catch (RuntimeException $error) { deployCheck(true, str_contains($error->getMessage(), 'changed during'), 'Refuse an empty directory replaced during installation'); }
120 // The replacement belongs to someone else. Recovery refuses it without deleting it.
121 try { deployCleanup($caseWork); throw new RuntimeException('Expected replacement ownership refusal.'); }
122 catch (RuntimeException $error) { deployCheck(true, str_contains($error->getMessage(), 'ownership differs'), 'Cleanup preserves the replacement directory'); }
123 deployCheck(true, is_dir($privateTarget), 'Replacement directory survives');
124 rmdir($privateTarget); rename($caseWork.'/original-private', $privateTarget);
125 }
126 deployCleanup($caseWork);
127 deployCheck(false, is_file($caseWork.'/deployment.json'), 'Cleanup completes for empty destinations');
128 deployCheck([], glob($caseWork.'/.lgv-tz-empty-*'), 'No empty-directory backups left');
129 if ($scenario === 'published') {
130 deployCheck('installed file', file_get_contents($privateTarget.'/fixture.txt'), 'Publish into the selected private directory');
131 deployCheck('installed file', file_get_contents($publicTarget.'/fixture.txt'), 'Publish into the selected public directory');
132 } else {
133 deployCheck(true, deploySameDirectory($privateTarget, $empty['private']), 'Preserve the original private directory inode');
134 deployCheck(true, deploySameDirectory($publicTarget, $empty['public']), 'Preserve the original public directory inode');
135 deployCheck($privatePermissions, fileperms($privateTarget) & 0777, 'Preserve original private directory permissions');
136 deployCheck($privateOwner, fileowner($privateTarget), 'Preserve original private directory owner');
137 deployCheck($privateGroup, filegroup($privateTarget), 'Preserve original private directory group');
138 deployCheck(false, is_file($privateTarget.'/.lgv-tz-install-owner'), 'Do not leave installation ownership in the original empty directory');
139 if ($scenario === 'new-file' || $scenario === 'public-new-file') {
140 deployCheck('keep this new file', file_get_contents($noteTarget.'/.operator-note'), 'Preserve a file added during setup');
141 }
142 }
143 }
144
145 // An unrelated nonempty directory is not classified as a fresh destination, including hidden files and links.
146 mkdir($work.'/nonempty', 0700); file_put_contents($work.'/nonempty/.hidden', 'keep');
147 deployCheck([], deployEmptyDirectories(['private' => $work.'/nonempty']), 'Nonempty directories are not treated as fresh targets');
148 symlink($work.'/nonempty', $work.'/linked');
149 try { deployEmptyDirectories(['private' => $work.'/linked']); throw new RuntimeException('Expected linked-target refusal.'); }
150 catch (RuntimeException $error) { deployCheck(true, str_contains($error->getMessage(), 'file or link'), 'Refuse linked destination directories'); }
151
153 $connection->exec('CREATE TABLE unrelated (value INTEGER)');
154 $connection->exec('INSERT INTO unrelated VALUES (42)');
155 foreach ([false, true] as $published) {
156 $token = bin2hex(random_bytes(32));
157 $connection->exec('CREATE TABLE timezones (id INTEGER)');
158 $connection->exec('CREATE TABLE lgv_tz_install_guard (token VARCHAR(64))');
159 $statement = $connection->prepare('INSERT INTO lgv_tz_install_guard VALUES (?)');
160 $statement->execute([$token]);
161 $statement = null;
162 $state = ['settings' => $settings, 'token' => $token, 'guard_created' => true, 'database_created' => false,
163 'published' => $published, 'private_stage' => $work.'/private-stage', 'public_stage' => $work.'/public-stage',
164 'private' => $work.'/private', 'public' => $work.'/public'];
165 foreach (['private', 'public', 'private_stage', 'public_stage'] as $key) {
166 mkdir($state[$key], 0700); file_put_contents($state[$key].'/.lgv-tz-install-owner', $token);
167 }
168 deploySave($work, $state);
169 file_put_contents($work.'/boundaries.zip', 'temporary'); file_put_contents($work.'/boundaries.json', 'temporary');
171 deployCheck($published, deployTableExists($connection, 'timezones'), 'Keep completed table / remove incomplete table');
172 deployCheck(false, deployTableExists($connection, 'lgv_tz_install_guard'), 'Remove installer guard');
173 deployCheck(42, (int)$connection->query('SELECT value FROM unrelated')->fetchColumn(), 'Preserve unrelated data');
174 deployCheck(false, is_dir($state['private_stage']), 'Remove private staging directory');
175 deployCheck(false, is_dir($state['public_stage']), 'Remove public staging directory');
176 deployCheck($published, is_dir($state['private']), 'Keep only published private directory');
177 deployCheck($published, is_dir($state['public']), 'Keep only published public directory');
178 deployCheck(false, is_file($work.'/boundaries.zip'), 'Remove ZIP');
179 deployCheck(false, is_file($work.'/boundaries.json'), 'Remove GeoJSON');
180 deployCheck(false, is_file($work.'/deployment.json'), 'Remove recovery state after successful cleanup');
181 if ($published) {
182 $connection->exec('DROP TABLE timezones');
183 deployRemoveTree($state['private']); deployRemoveTree($state['public']);
184 }
185 }
186 $connection->exec('CREATE TABLE timezones (id INTEGER)');
187 $connection->exec("CREATE TABLE lgv_tz_install_guard (token VARCHAR(64))");
188 $connection->exec("INSERT INTO lgv_tz_install_guard VALUES ('different-owner')");
189 $state['published'] = false; $state['guard_created'] = true;
190 deploySave($work, $state);
191 file_put_contents($work.'/boundaries.zip', 'temporary');
192 try { deployCleanup($work); throw new RuntimeException('Expected ownership refusal.'); }
193 catch (RuntimeException $error) { deployCheck(true, str_contains($error->getMessage(), 'ownership differs'), 'Reject foreign database ownership'); }
194 deployCheck(true, deployTableExists($connection, 'timezones'), 'Preserve foreign table');
195 deployCheck(false, is_file($work.'/boundaries.zip'), 'Downloads removed before ownership refusal');
196 deployCheck(true, is_file($work.'/deployment.json'), 'Retain refused cleanup record');
197 $connection->exec('DROP TABLE lgv_tz_install_guard');
198 $state['guard_created'] = false;
199 mkdir($state['private'], 0700);
200 file_put_contents($state['private'].'/.lgv-tz-install-owner', 'different-directory-owner');
201 deploySave($work, $state);
202 try { deployCleanup($work); throw new RuntimeException('Expected directory refusal.'); }
203 catch (RuntimeException $error) { deployCheck(true, str_contains($error->getMessage(), 'ownership differs'), 'Reject foreign directory ownership'); }
204 deployCheck(true, is_dir($state['private']), 'Preserve foreign directory');
205 deployCheck(0600, fileperms($work.'/deployment.json') & 0777, 'Recovery credentials are owner-only');
206
207 // Drive the real prompt flow with an in-memory terminal. An existing table must stop setup before any download.
208 mkdir($work.'/web', 0700); mkdir($work.'/fresh-run', 0700);
209 $answers = [$driver, $settings['host'], (string)$settings['port'], $name, $settings['user'], $settings['password']];
210 if ($driver === 'pgsql') { $answers[] = $settings['admin_database']; }
211 $answers = array_merge($answers, ['n', $work.'/web', 'https://example.com/nested', 'service', $work.'/installer-private', posix_getgrgid(posix_getegid())['name']]);
212 $terminal = fopen('php://memory', 'w+b');
213 fwrite($terminal, implode("\n", $answers)."\n"); rewind($terminal);
214 try { deployRun($work.'/fresh-run', true, $terminal); throw new RuntimeException('Expected existing-table refusal.'); }
215 catch (RuntimeException $error) { deployCheck(true, str_contains($error->getMessage(), 'already contains'), 'Real installer refuses existing timezone data'); }
216 finally { fclose($terminal); }
217 deployCleanup($work.'/fresh-run');
218 deployCheck(true, deployTableExists($connection, 'timezones'), 'Refused installation preserves existing table');
219 deployCheck([], glob($work.'/web/*'), 'Refused installation publishes no endpoint');
220 deployCheck(false, is_dir($work.'/installer-private'), 'Refused installation leaves no private application');
221 deployCheck(false, is_file($work.'/fresh-run/deployment.json'), 'Refused installation cleanup completes');
222
223 // The same real prompt flow must accept an existing empty private directory and empty public service directory.
224 mkdir($work.'/installer-private', 0750); mkdir($work.'/web/service', 0755);
225 $originalEmpty = deployEmptyDirectories(['private' => $work.'/installer-private', 'public' => $work.'/web/service']);
226 $terminal = fopen('php://memory', 'w+b');
227 fwrite($terminal, implode("\n", $answers)."\n"); rewind($terminal);
228 try { deployRun($work.'/fresh-run', true, $terminal); throw new RuntimeException('Expected existing-table refusal.'); }
229 catch (RuntimeException $error) { deployCheck(true, str_contains($error->getMessage(), 'already contains'), 'Empty destinations reach database validation'); }
230 finally { fclose($terminal); }
231 deployCleanup($work.'/fresh-run');
232 foreach (['private' => $work.'/installer-private', 'public' => $work.'/web/service'] as $key => $path) {
233 deployCheck(true, deploySameDirectory($path, $originalEmpty[$key]), 'Preserve pre-existing empty '.$key.' directory on early failure');
234 deployCheck(true, deployDirectoryIsEmpty($path), 'Early failure leaves '.$key.' directory empty');
235 }
236 $connection->exec('DROP TABLE timezones');
237 $terminal = fopen('php://memory', 'w+b');
238 fwrite($terminal, implode("\n", $answers)."\n"); rewind($terminal);
239 try {
240 deployRun($work.'/fresh-run', true, $terminal, static function(): void { throw new RuntimeException('Fixture fresh Composer failure'); });
241 throw new RuntimeException('Expected fresh package failure.');
242 } catch (RuntimeException $error) { deployCheck(true, str_contains($error->getMessage(), 'Fixture fresh Composer failure'), 'Empty destinations reach package installation'); }
243 finally { fclose($terminal); }
244 deployCleanup($work.'/fresh-run');
245 deployCheck(false, deployTableExists($connection, 'lgv_tz_install_guard'), 'Package failure removes only its database guard');
246 foreach (['private' => $work.'/installer-private', 'public' => $work.'/web/service'] as $key => $path) {
247 deployCheck(true, deploySameDirectory($path, $originalEmpty[$key]), 'Package failure retains original '.$key.' directory');
248 deployCheck(true, deployDirectoryIsEmpty($path), 'Package failure leaves '.$key.' directory empty');
249 }
250
251 // A database recorded as newly created by this installer is removed on failure, even before table loading.
252 $ownedName = $name.'_owned';
253 $admin->exec('CREATE DATABASE '.deployIdentifier($ownedName, $driver));
254 $ownedState = $state;
255 $ownedState['settings']['database'] = $ownedName;
256 $ownedState['guard_created'] = false; $ownedState['database_created'] = true;
257 foreach (['private', 'public', 'private_stage', 'public_stage'] as $key) { $ownedState[$key] = $work.'/absent-'.$key; }
258 deploySave($work.'/fresh-run', $ownedState);
259 deployCleanup($work.'/fresh-run');
260 $statement = $admin->prepare($driver === 'pgsql' ? 'SELECT COUNT(*) FROM pg_database WHERE datname = ?'
261 : 'SELECT COUNT(*) FROM information_schema.schemata WHERE schema_name = ?');
262 $statement->execute([$ownedName]);
263 deployCheck(0, (int)$statement->fetchColumn(), 'Rollback removes newly created database');
264 $statement = null;
265
266 // Refresh a populated, installer-owned service with a small local package fixture; no downloads are needed.
267 $fixtureAutoloader = require dirname(__DIR__).'/vendor/autoload.php';
268 $database = new LGV_TZ_Lookup_Database($name, $settings['user'], $settings['password'], $driver, $settings['host'], $settings['port']);
269 $database->reset_database();
270 $database->store_entity(new LGV_TZ_Lookup_Entity('Asia/Tokyo', ['east' => 4, 'west' => 0, 'north' => 4, 'south' => 0],
271 [[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]]));
272 $private = $work.'/refresh-private'; $public = $work.'/web/refresh-service';
273 mkdir($private.'/app/vendor', 0700, true); mkdir($private.'/tools', 0700); mkdir($public, 0700);
274 $originalToken = bin2hex(random_bytes(32));
275 foreach ([$private, $public] as $directory) { file_put_contents($directory.'/.lgv-tz-install-owner', $originalToken); }
276 $config = "<?php\n// Keep this comment and the original credentials.\n";
277 foreach (['g_dbName' => $name, 'g_dbUserName' => $settings['user'], 'g_dbPassword' => $settings['password'],
278 'g_dbType' => $driver, 'g_dbHost' => $settings['host'], 'g_dbPort' => $settings['port'], 'g_server_secret' => 'kept-secret'] as $key => $value) {
279 $config .= '$'.$key.' = '.var_export($value, true).";\n";
280 }
281 file_put_contents($private.'/config.php', $config);
282 file_put_contents($private.'/installation.json', json_encode(['release' => 'original-boundaries', 'archive_sha256' => 'original-digest', 'driver' => $driver, 'polygons' => 1]));
283 file_put_contents($private.'/app/vendor/autoload.php', '<?php');
284 file_put_contents($public.'/index.php', '<?php echo "old endpoint";');
285 file_put_contents($private.'/notes.txt', 'keep private notes'); file_put_contents($public.'/extra.txt', 'keep public file');
286 copy(dirname(__DIR__).'/update.sh', $private.'/update.sh');
287 foreach (['update.php', 'Setup.php'] as $file) { copy(dirname(__DIR__).'/tools/'.$file, $private.'/tools/'.$file); }
288 $refreshWork = $work.'/refresh-run'; mkdir($refreshWork, 0700);
289 $existing = deployExistingInstallation($private, $public, $settings);
290 $group = posix_getegid();
291 $serviceUrl = 'https://example.com/nested/refresh-service/';
292 $snapshot = static function() use ($connection, $driver): array {
293 $polygon = $driver === 'pgsql' ? "encode(polygon, 'hex')" : 'HEX(polygon)';
294 return $connection->query('SELECT id, tzname, east, west, north, south, '.$polygon.' AS polygon FROM timezones ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
295 };
296 $rowsBefore = $snapshot();
297 try {
298 deployRefresh($refreshWork, $settings, $private, $public, $group, $serviceUrl, $existing,
299 static function(): void { throw new RuntimeException('Fixture Composer failure'); });
300 throw new RuntimeException('Expected failed refresh.');
301 } catch (RuntimeException $error) { deployCheck(true, str_contains($error->getMessage(), 'Fixture Composer failure'), 'Failed package install refuses publication'); }
302 // Simulate interruption between the two directory publications, then run the shell's recovery entry point.
303 $refreshState = json_decode(file_get_contents($refreshWork.'/deployment.json'), true);
304 rename($private, $refreshState['private_backup']); rename($refreshState['private_stage'], $private);
305 deployCleanup($refreshWork);
306 deployCheck($config, file_get_contents($private.'/config.php'), 'Interrupted refresh restores original configuration');
307 deployCheck('<?php echo "old endpoint";', file_get_contents($public.'/index.php'), 'Interrupted refresh preserves original endpoint');
308 deployCheck($originalToken, file_get_contents($private.'/.lgv-tz-install-owner'), 'Interrupted refresh restores original ownership');
309 deployCheck($rowsBefore, $snapshot(), 'Failed refresh leaves polygons unchanged');
310
311 // The real installer loads only the staged application's autoloader. Avoid the test checkout taking precedence.
312 class_exists(LGV_TZ_Lookup_Query::class); class_exists(Composer\InstalledVersions::class);
313 $fixtureAutoloader->unregister();
314
315 // Build the package directories first, then use the real current source and one known location.
316 $installFixture = static function(string $app, string $driver) use ($options, $work): void {
317 $locationFixture = '<?php $test_locations_param_array = [["title" => "Fixture location", "params" => ["lng" => 2, "lat" => 2], "result" => "Asia/Tokyo"]];';
318 if (isset($options['real-composer'])) {
319 $source = $work.'/fixture-package';
320 if (!is_dir($source)) {
321 mkdir($source, 0755);
322 copy(dirname(__DIR__).'/composer.json', $source.'/composer.json');
323 copy(dirname(__DIR__).'/LICENSE', $source.'/LICENSE');
324 deployCopyTree(dirname(__DIR__).'/src', $source.'/src');
325 file_put_contents($source.'/src/TestLocations.php', $locationFixture);
326 }
328 return;
329 }
330 $package = $app.'/vendor/littlegreenviper/lgv_tz_lookup';
331 mkdir($package, 0755, true);
332 deployCopyTree(dirname(__DIR__).'/src', $package.'/src');
333 file_put_contents($package.'/src/TestLocations.php', $locationFixture);
334 file_put_contents($app.'/vendor/autoload.php', '<?php require '.var_export(dirname(__DIR__).'/vendor/autoload.php', true).';');
335 Composer\InstalledVersions::reload(['root' => ['name' => 'fixture/application', 'install_path' => $app],
336 'versions' => [LGV_TZ_Lookup_Setup::PACKAGE => ['install_path' => $package]]]);
337 };
338 $refreshAnswers = [$driver, $settings['host'], (string)$settings['port'], $name, $settings['user'], $settings['password']];
339 if ($driver === 'pgsql') { $refreshAnswers[] = $settings['admin_database']; }
340 $refreshAnswers = array_merge($refreshAnswers, ['n', $work.'/web', 'https://example.com/nested', 'refresh-service', $private, posix_getgrgid($group)['name']]);
341 $terminal = fopen('php://memory', 'w+b'); fwrite($terminal, implode("\n", $refreshAnswers)."\n"); rewind($terminal);
342 ob_start();
343 try { deployRun($refreshWork, true, $terminal, $installFixture); $report = ob_get_contents(); }
344 finally { ob_end_clean(); fclose($terminal); }
345 deployCheck($config, file_get_contents($private.'/config.php'), 'Refresh preserves exact configuration bytes and secret');
346 deployCheck('original-boundaries', json_decode(file_get_contents($private.'/installation.json'), true)['release'], 'Refresh retains boundary release');
347 deployCheck($rowsBefore, $snapshot(), 'Successful refresh leaves polygons unchanged');
348 deployCheck(42, (int)$connection->query('SELECT value FROM unrelated')->fetchColumn(), 'Refresh preserves unrelated data');
349 deployCheck('keep private notes', file_get_contents($private.'/notes.txt'), 'Refresh preserves additional private files');
350 deployCheck('keep public file', file_get_contents($public.'/extra.txt'), 'Refresh preserves additional public files');
351 deployCheck(true, str_contains($report, 'Test request: '.$serviceUrl.'?test&secret=kept-secret'), 'Refresh prints full test URL with existing secret');
352 deployCheck(false, is_file($refreshWork.'/deployment.json'), 'Successful refresh removes recovery record');
353 deployCheck([], glob($work.'/.lgv-tz-previous-*'), 'Successful refresh removes private backup');
354 deployCheck([], glob($work.'/web/.lgv-tz-previous-*'), 'Successful refresh removes public backup');
355 $process = proc_open([PHP_BINARY, $public.'/index.php', 'll=2,2', 'secret=kept-secret'],
356 [0 => ['file', '/dev/null', 'r'], 1 => ['pipe', 'w'], 2 => STDERR], $pipes);
357 $response = stream_get_contents($pipes[1]); fclose($pipes[1]);
358 deployCheck(0, proc_close($process), 'Refreshed public entry point runs');
359 deployCheck('Asia/Tokyo', $response, 'Refreshed endpoint uses preserved private config and existing polygons');
360
361 $refreshedEndpoint = file_get_contents($public.'/index.php');
362 $existing = deployExistingInstallation($private, $public, $settings);
363 // A real deployment loads one Composer application per PHP process. The fixture path can test failures again in-process.
364 if (!isset($options['real-composer'])) {
365 $badFixture = static function(string $app, string $driver) use ($installFixture): void {
366 $installFixture($app, $driver);
367 file_put_contents($app.'/vendor/littlegreenviper/lgv_tz_lookup/src/TestLocations.php',
368 '<?php $test_locations_param_array = [["params" => ["lng" => 2, "lat" => 2], "result" => "Wrong/Zone"]];');
369 };
370 try {
371 deployRefresh($refreshWork, $settings, $private, $public, $group, $serviceUrl, $existing, $badFixture);
372 throw new RuntimeException('Expected lookup-validation failure.');
373 } catch (RuntimeException $error) { deployCheck(true, str_contains($error->getMessage(), 'known-location tests failed'), 'Refuse code that fails existing boundary tests'); }
374 deployCleanup($refreshWork);
375 deployCheck($refreshedEndpoint, file_get_contents($public.'/index.php'), 'Failed validation preserves the working public entry point');
376 deployCheck($rowsBefore, $snapshot(), 'Failed validation leaves populated boundaries unchanged');
377 deployCheck($config, file_get_contents($private.'/config.php'), 'Failed validation preserves configuration');
378 }
379 $differentSettings = $settings; $differentSettings['database'] .= '_different';
380 try { deployExistingInstallation($private, $public, $differentSettings); throw new RuntimeException('Expected configuration refusal.'); }
381 catch (RuntimeException $error) { deployCheck(true, str_contains($error->getMessage(), 'settings differ'), 'Refuse refresh with different database settings'); }
382 echo 'Passed '.$checks.' '.$driver." deployment recovery checks.\n";
383} finally {
384 $connection = null; $statement = null; $database = null; $snapshot = null;
385 $admin->exec('DROP DATABASE '.deployIdentifier($name, $driver));
387}
388/** \endcond */
static install(string $directory, string $driver, string $packagePath='', string $version='^1.4')
Install the library and optional loader, then require its Composer autoloader.
Definition Setup.php:150
const PACKAGE
The package installed by both command-line tools.
Definition Setup.php:38
$options
Definition composer.php:6
$connection
Definition composer.php:31
$database
Definition composer.php:52
if(!is_file($autoload)) $checks
Definition composer.php:11
$driver
Definition databases.php:38
$name
if($driver==='pgsql') $admin
$config
Definition server.php:43
$directory
Definition demo.php:107
$process
Definition demo.php:113
$source
Definition demo.php:67
$error
Definition demo.php:116
deployCheck($expected, $actual, string $label)
This checks an installer result.
Definition deploy.php:41
$work
Definition update.php:40
$settings
Definition update.php:34
deployConnection(array $settings, bool $administrative=false)
This opens the configured application or administrative database.
Definition deploy.php:57
deployDirectoryIsEmpty(string $path)
Check an empty directory without following links. Hidden files also count as contents.
Definition deploy.php:157
deployRemoveTree(string $directory)
This removes a directory tree without following symlinks.
Definition deploy.php:145
deployServiceUrl(string $webUrl, string $endpoint)
Build the browser URL from the selected web directory's URL and the new service subdirectory.
Definition deploy.php:128
deployCleanup(string $work)
This removes installer downloads, and rolls back only installation-owned resources on failure....
Definition deploy.php:438
deployEmptyDirectories(array $destinations)
Record existing empty destinations so fresh installation can publish into them and roll back exactly.
Definition deploy.php:174
deployIdentifier(string $name, string $driver)
This quotes a database identifier for the selected backend.
Definition deploy.php:40
deployRun(string $work, bool $noSecret=false, $terminal=null, ?callable $install=null)
This gathers settings, loads and tests the Composer package, then publishes the endpoint....
Definition deploy.php:498
deploySameDirectory(string $path, array $original)
Recognize the exact original directory, including after it is moved aside for publication.
Definition deploy.php:164
deploySave(string $work, array $state)
This saves recovery state atomically, with credentials readable only by the installer user.
Definition deploy.php:47
deployTableExists(PDO $connection, string $table)
This checks for an existing table without attempting to create or replace it.
Definition deploy.php:80
deployPublishDirectories(array $state)
Publish fresh staged code, retaining original empty directories until installation is committed.
Definition deploy.php:192
deployCopyTree(string $source, string $destination)
Copy an installation for staging, preserving additional files, permissions, and symbolic links.
Definition deploy.php:280
deployRefresh(string $work, array $settings, string $private, string $public, int $group, string $serviceUrl, array $existing, callable $install)
Refresh owned installed code using existing polygons, retaining the configuration and secret.
Definition deploy.php:337
deployExistingInstallation(string $private, string $public, array $settings)
Verify an existing installer-owned pair of directories and its unchanged database configuration.
Definition deploy.php:235