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 This installs a tested HTTP timezone service, with private configuration and a default server secret.
28
29 deploy.sh supplies a private working directory. The worker asks for settings through the terminal, installs
30 the Composer package, loads current boundaries, and checks known locations before publishing a small endpoint.
31 A separate cleanup process removes downloads and rolls back an incomplete installation after errors or OOM.
32 Database ownership is verified with a random guard token before cleanup can remove a newly loaded table.
33*/
34
35declare(strict_types=1);
36require_once __DIR__.'/Setup.php';
37
38/***************************************************************************************************************************/
39/** \brief This quotes a database identifier for the selected backend. */
40function deployIdentifier(string $name, string $driver): string {
41 $quote = $driver === 'pgsql' ? '"' : '`';
42 return $quote.str_replace($quote, $quote.$quote, $name).$quote;
43}
44
45/***************************************************************************************************************************/
46/** \brief This saves recovery state atomically, with credentials readable only by the installer user. */
47function deploySave(string $work, array $state): void {
48 $file = $work.'/deployment.json';
49 if (false === file_put_contents($file.'.new', json_encode($state, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)) ||
50 !chmod($file.'.new', 0600) || !rename($file.'.new', $file)) {
51 throw new RuntimeException('Could not record installation recovery information.');
52 }
53}
54
55/***************************************************************************************************************************/
56/** \brief This opens the configured application or administrative database. \throws PDOException on failure. */
57function deployConnection(array $settings, bool $administrative = false): PDO {
58 $driver = $settings['driver'];
59 $dsn = $driver.':host='.$settings['host'].';port='.$settings['port'];
60 if (!$administrative || $driver === 'pgsql') {
61 $dsn .= ';dbname='.($administrative ? $settings['admin_database'] : $settings['database']);
62 }
63 $dsn .= $driver === 'mysql' ? ';charset=utf8mb4' : ';options=--client_encoding=UTF8';
64 return new PDO($dsn, $settings['user'], $settings['password'], [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
65}
66
67/***************************************************************************************************************************/
68/** \brief This prevents two installers from loading the same database concurrently. */
69function deployLock(PDO $connection): void {
70 $sql = $connection->getAttribute(PDO::ATTR_DRIVER_NAME) === 'pgsql'
71 ? "SELECT pg_try_advisory_lock(hashtext('LGV_TZ_Lookup.install'))"
72 : "SELECT GET_LOCK(CONCAT('lgv_tz_', SHA2(DATABASE(), 224)), 0)";
73 if (!$connection->query($sql)->fetchColumn()) {
74 throw new RuntimeException('Another timezone installation is using this database.');
75 }
76}
77
78/***************************************************************************************************************************/
79/** \brief This checks for an existing table without attempting to create or replace it. */
80function deployTableExists(PDO $connection, string $table): bool {
81 $schema = $connection->getAttribute(PDO::ATTR_DRIVER_NAME) === 'pgsql' ? 'current_schema()' : 'DATABASE()';
82 $statement = $connection->prepare('SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = '.$schema.' AND table_name = ?');
83 $statement->execute([$table]);
84 return (int)$statement->fetchColumn() > 0;
85}
86
87/***************************************************************************************************************************/
88/**
89 \brief This asks for a setting, with a default and optional password hiding.
90 \throws RuntimeException if terminal input ends, or password echo cannot be disabled.
91*/
92function deployAsk($terminal, string $label, string $default = '', bool $hidden = false): string {
93 fwrite(STDOUT, $label.($default === '' ? '' : ' ['.$default.']').': ');
94 $mode = null;
95 if ($hidden && stream_isatty($terminal)) {
96 $process = proc_open(['stty', '-g'], [0 => $terminal, 1 => ['pipe', 'w'], 2 => STDERR], $pipes);
97 if (false === $process) { throw new RuntimeException('Could not read terminal settings.'); }
98 $mode = trim(stream_get_contents($pipes[1]));
99 fclose($pipes[1]);
100 if (proc_close($process) !== 0 || $mode === '') { throw new RuntimeException('Could not read terminal settings.'); }
101 $process = proc_open(['stty', '-echo'], [0 => $terminal, 1 => STDOUT, 2 => STDERR], $pipes);
102 if (false === $process || proc_close($process) !== 0) { throw new RuntimeException('Could not hide password input.'); }
103 }
104 try {
105 if (stream_isatty($terminal)) {
106 // fgets() can retry an interrupted terminal read indefinitely. Poll readiness so Ctrl+C can unwind
107 // through the finally block and restore password echo before the shell starts cleanup.
108 do {
109 $read = [$terminal]; $write = $except = null;
110 $ready = @stream_select($read, $write, $except, 0, 250000);
111 } while ($ready === false || $ready === 0);
112 }
113 $answer = fgets($terminal);
114 if (false === $answer) { throw new RuntimeException('Installation input ended before setup was complete.'); }
115 $answer = $hidden ? rtrim($answer, "\r\n") : trim($answer);
116 return $answer === '' ? $default : $answer;
117 } finally {
118 if ($mode !== null) {
119 $process = proc_open(['stty', $mode], [0 => $terminal, 1 => STDOUT, 2 => STDERR], $pipes);
120 if (is_resource($process)) { proc_close($process); }
121 fwrite(STDOUT, "\n");
122 }
123 }
124}
125
126/***************************************************************************************************************************/
127/** \brief Build the browser URL from the selected web directory's URL and the new service subdirectory. */
128function deployServiceUrl(string $webUrl, string $endpoint): string {
129 $webUrl = trim($webUrl);
130 $parts = parse_url($webUrl);
131 if (filter_var($webUrl, FILTER_VALIDATE_URL) === false || !is_array($parts) ||
132 !in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true) ||
133 isset($parts['user']) || isset($parts['pass']) || isset($parts['query']) || isset($parts['fragment']) ||
134 str_contains($webUrl, '\\')) {
135 throw new InvalidArgumentException('Enter the full http:// or https:// URL of the web directory, without credentials, a query, or a fragment.');
136 }
137 if (!preg_match('/^[a-zA-Z0-9_-]+$/D', $endpoint)) {
138 throw new InvalidArgumentException('Use a simple service directory name.');
139 }
140 return rtrim($webUrl, '/').'/'.$endpoint.'/';
141}
142
143/***************************************************************************************************************************/
144/** \brief This removes a directory tree without following symlinks. */
145function deployRemoveTree(string $directory): void {
146 if (!is_dir($directory) || is_link($directory)) { return; }
147 $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST);
148 foreach ($files as $file) {
149 $ok = $file->isDir() && !$file->isLink() ? rmdir($file->getPathname()) : unlink($file->getPathname());
150 if (!$ok) { throw new RuntimeException('Could not remove '.$file->getPathname()); }
151 }
152 if (!rmdir($directory)) { throw new RuntimeException('Could not remove '.$directory); }
153}
154
155/***************************************************************************************************************************/
156/** \brief Check an empty directory without following links. Hidden files also count as contents. */
157function deployDirectoryIsEmpty(string $path): bool {
158 clearstatcache(true, $path);
159 return is_dir($path) && !is_link($path) && !(new FilesystemIterator($path, FilesystemIterator::SKIP_DOTS))->valid();
160}
161
162/***************************************************************************************************************************/
163/** \brief Recognize the exact original directory, including after it is moved aside for publication. */
164function deploySameDirectory(string $path, array $original): bool {
165 clearstatcache(true, $path);
166 if (!is_dir($path) || is_link($path)) { return false; }
167 $stat = lstat($path);
168 return $stat !== false &&
169 $stat['dev'] === $original['device'] && $stat['ino'] === $original['inode'];
170}
171
172/***************************************************************************************************************************/
173/** \brief Record existing empty destinations so fresh installation can publish into them and roll back exactly. */
174function deployEmptyDirectories(array $destinations): array {
175 $empty = [];
176 foreach ($destinations as $key => $path) {
177 if (!file_exists($path) && !is_link($path)) { continue; }
178 if (is_link($path) || !is_dir($path)) {
179 throw new RuntimeException('Installation destination must be a new or empty directory, not a file or link: '.$path);
180 }
181 if (deployDirectoryIsEmpty($path)) {
182 $stat = lstat($path);
183 $empty[$key] = ['device' => $stat['dev'], 'inode' => $stat['ino'],
184 'backup' => dirname($path).'/.lgv-tz-empty-'.bin2hex(random_bytes(12))];
185 }
186 }
187 return $empty;
188}
189
190/***************************************************************************************************************************/
191/** \brief Publish fresh staged code, retaining original empty directories until installation is committed. */
192function deployPublishDirectories(array $state): void {
193 foreach (['private', 'public'] as $key) {
194 $path = $state[$key];
195 $original = $state['empty_directories'][$key] ?? null;
196 if ($original !== null) {
197 if (!deploySameDirectory($path, $original) || !deployDirectoryIsEmpty($path)) {
198 throw new RuntimeException('The selected empty directory changed during installation; refusing to replace '.$path);
199 }
200 if (file_exists($original['backup']) || is_link($original['backup']) || !rename($path, $original['backup'])) {
201 throw new RuntimeException('Could not retain the original empty directory for recovery: '.$path);
202 }
203 } elseif (file_exists($path) || is_link($path)) {
204 throw new RuntimeException('Installation destination appeared during setup; refusing to replace '.$path);
205 }
206 if (!rename($state[$key.'_stage'], $path)) {
207 throw new RuntimeException('Could not publish the installation directories.');
208 }
209 }
210}
211
212/***************************************************************************************************************************/
213/** \brief Restore the original empty directories on failure, or remove their empty backups after success. */
214function deployEmptyDirectoryCleanup(array $state): void {
215 foreach ($state['empty_directories'] ?? [] as $key => $original) {
216 $backup = $original['backup'];
217 if (!file_exists($backup) && !is_link($backup)) { continue; }
218 if (!deploySameDirectory($backup, $original)) {
219 throw new RuntimeException('Original directory identity differs; refusing recovery for '.$backup);
220 }
221 if (!empty($state['published'])) {
222 if (!deployDirectoryIsEmpty($backup) || !rmdir($backup)) {
223 throw new RuntimeException('Could not remove the original empty-directory backup: '.$backup);
224 }
225 } else {
226 if (file_exists($state[$key]) || is_link($state[$key]) || !rename($backup, $state[$key])) {
227 throw new RuntimeException('Could not restore the original empty directory: '.$state[$key]);
228 }
229 }
230 }
231}
232
233/***************************************************************************************************************************/
234/** \brief Verify an existing installer-owned pair of directories and its unchanged database configuration. */
235function deployExistingInstallation(string $private, string $public, array $settings): array {
236 foreach ([$private, $public] as $directory) {
237 if (!is_dir($directory) || is_link($directory) || !is_file($directory.'/.lgv-tz-install-owner') ||
238 is_link($directory.'/.lgv-tz-install-owner')) {
239 throw new RuntimeException('Existing directories are not a matching installer-owned service; choose new paths.');
240 }
241 }
242 $token = trim(file_get_contents($private.'/.lgv-tz-install-owner'));
243 if (!preg_match('/^[a-f0-9]{64}$/D', $token) || !hash_equals($token, trim(file_get_contents($public.'/.lgv-tz-install-owner')))) {
244 throw new RuntimeException('Installation directory ownership differs; refusing to refresh.');
245 }
246 foreach (['app', 'tools'] as $directory) {
247 if (!is_dir($private.'/'.$directory) || is_link($private.'/'.$directory)) {
248 throw new RuntimeException('Existing installation contains an unsupported linked directory; refusing to refresh.');
249 }
250 }
251 if (!is_file($public.'/index.php') || is_link($public.'/index.php')) {
252 throw new RuntimeException('Existing public entry point is missing or linked; refusing to refresh.');
253 }
254 foreach (['config.php', 'installation.json', 'app/vendor/autoload.php', 'update.sh', 'tools/update.php', 'tools/Setup.php'] as $file) {
255 if (!is_file($private.'/'.$file) || is_link($private.'/'.$file)) {
256 throw new RuntimeException('Existing installation is incomplete; refusing to refresh.');
257 }
258 }
259 $configuration = (static function(string $file): array {
260 require $file;
261 return ['settings' => ['database' => $g_dbName ?? '', 'user' => $g_dbUserName ?? '', 'password' => $g_dbPassword ?? '',
262 'driver' => strtolower($g_dbType ?? 'mysql'), 'host' => $g_dbHost ?? '127.0.0.1',
263 'port' => (int)($g_dbPort ?? (($g_dbType ?? 'mysql') === 'pgsql' ? 5432 : 3306))],
264 'secret' => $g_server_secret ?? ''];
265 })($private.'/config.php');
266 foreach (['database', 'user', 'password', 'driver', 'host', 'port'] as $key) {
267 if ($settings[$key] !== $configuration['settings'][$key]) {
268 throw new RuntimeException('Database settings differ from the existing private configuration; use the original settings to refresh.');
269 }
270 }
271 $metadata = json_decode(file_get_contents($private.'/installation.json'), true, 512, JSON_THROW_ON_ERROR);
272 if (!is_array($metadata) || ($metadata['driver'] ?? '') !== $settings['driver'] || !is_string($configuration['secret'])) {
273 throw new RuntimeException('Existing installation metadata is invalid; refusing to refresh.');
274 }
275 return ['token' => $token, 'metadata' => $metadata, 'secret' => $configuration['secret']];
276}
277
278/***************************************************************************************************************************/
279/** \brief Copy an installation for staging, preserving additional files, permissions, and symbolic links. */
280function deployCopyTree(string $source, string $destination): void {
281 $mode = fileperms($source) & 0777;
282 if (!mkdir($destination, $mode) || !chmod($destination, $mode)) { throw new RuntimeException('Could not stage the installation.'); }
283 foreach (new DirectoryIterator($source) as $entry) {
284 if ($entry->isDot()) { continue; }
285 $target = $destination.'/'.$entry->getFilename();
286 if ($entry->isLink()) { $ok = symlink(readlink($entry->getPathname()), $target); }
287 elseif ($entry->isDir()) { deployCopyTree($entry->getPathname(), $target); continue; }
288 else { $ok = copy($entry->getPathname(), $target) && chmod($target, $entry->getPerms() & 0777); }
289 if (!$ok) { throw new RuntimeException('Could not stage '.$entry->getFilename()); }
290 }
291}
292
293/***************************************************************************************************************************/
294/** \brief Generate the public entry point with the chosen private configuration and package paths. */
295function deployEndpointCode(string $private, string $relativePackage, string $licenseHeader): string {
296 return $licenseHeader.
297 "define('__CONFIG_FILE_', ".var_export($private.'/config.php', true).");\n".
298 "ini_set('display_errors', '0');\ntry {\n require ".var_export($private.'/app/vendor/autoload.php', true).";\n".
299 ' require '.var_export($private.$relativePackage.'/src/index.php', true).";\n} catch (Throwable \$error) {\n".
300 " http_response_code(503);\n error_log('Timezone service: '.\$error->getMessage());\n echo 'Timezone service unavailable.';\n}\n";
301}
302
303/***************************************************************************************************************************/
304/** \brief Restore prior directories after an interrupted refresh, or remove backups after publication. Never changes database tables. */
305function deployRefreshCleanup(string $work, array $state): void {
306 $verify = static function(string $path, string $token): void {
307 if (is_link($path) || !is_dir($path) || !is_file($path.'/.lgv-tz-install-owner') || is_link($path.'/.lgv-tz-install-owner') ||
308 !hash_equals($token, trim(file_get_contents($path.'/.lgv-tz-install-owner')))) {
309 throw new RuntimeException('Refresh directory ownership differs; refusing recovery for '.$path);
310 }
311 };
312 foreach (['private', 'public'] as $key) {
313 $backup = $state[$key.'_backup'];
314 if (!file_exists($backup) && !is_link($backup)) { continue; }
315 $verify($backup, $state['previous_token']);
316 if (empty($state['published'])) {
317 if (file_exists($state[$key]) || is_link($state[$key])) {
318 $verify($state[$key], $state['token']);
319 deployRemoveTree($state[$key]);
320 }
321 if (!rename($backup, $state[$key])) { throw new RuntimeException('Could not restore the prior installation.'); }
322 } else {
323 $verify($state[$key], $state['token']);
324 deployRemoveTree($backup);
325 }
326 }
327 foreach (['private_stage', 'public_stage'] as $key) {
328 if (!file_exists($state[$key]) && !is_link($state[$key])) { continue; }
329 $verify($state[$key], $state['token']);
330 deployRemoveTree($state[$key]);
331 }
332 unlink($work.'/deployment.json');
333}
334
335/***************************************************************************************************************************/
336/** \brief Refresh owned installed code using existing polygons, retaining the configuration and secret. */
337function deployRefresh(string $work, array $settings, string $private, string $public, int $group,
338 string $serviceUrl, array $existing, callable $install): void {
341 if (deployTableExists($connection, 'lgv_tz_install_guard')) {
342 throw new RuntimeException('An installation recovery table remains; finish its cleanup before refreshing.');
343 }
344 $count = (int)$connection->query('SELECT COUNT(*) FROM timezones')->fetchColumn();
345 if ($count === 0) { throw new RuntimeException('The existing boundary table is empty; refusing to refresh.'); }
346 $suffix = bin2hex(random_bytes(12));
347 $state = ['refresh' => true, 'token' => bin2hex(random_bytes(32)), 'previous_token' => $existing['token'],
348 'private' => $private, 'public' => $public, 'private_stage' => dirname($private).'/.lgv-tz-install-'.$suffix,
349 'public_stage' => dirname($public).'/.lgv-tz-install-'.$suffix,
350 'private_backup' => dirname($private).'/.lgv-tz-previous-'.$suffix,
351 'public_backup' => dirname($public).'/.lgv-tz-previous-'.$suffix, 'published' => false];
352 deploySave($work, $state);
353 foreach (['private', 'public'] as $key) {
354 // Establish ownership before copying, so cleanup can always identify even an incomplete stage.
355 if (!mkdir($state[$key.'_stage'], 0700) ||
356 file_put_contents($state[$key.'_stage'].'/.lgv-tz-install-owner', $state['token']) === false) {
357 throw new RuntimeException('Could not create refresh staging directories.');
358 }
359 foreach (new DirectoryIterator($state[$key]) as $entry) {
360 if ($entry->isDot() || $entry->getFilename() === '.lgv-tz-install-owner') { continue; }
361 $target = $state[$key.'_stage'].'/'.$entry->getFilename();
362 if ($entry->isLink()) { $ok = symlink(readlink($entry->getPathname()), $target); }
363 elseif ($entry->isDir()) { deployCopyTree($entry->getPathname(), $target); continue; }
364 else { $ok = copy($entry->getPathname(), $target) && chmod($target, $entry->getPerms() & 0777); }
365 if (!$ok) { throw new RuntimeException('Could not stage the existing installation.'); }
366 }
367 }
368 $privateStage = $state['private_stage'];
369 $publicStage = $state['public_stage'];
370 echo "Refreshing installed code; keeping the existing boundaries, configuration, and secret.\n";
371 deployRemoveTree($privateStage.'/app');
372 mkdir($privateStage.'/app', 0755);
373 $install($privateStage.'/app', $settings['driver'], dirname(__DIR__));
374 $package = Composer\InstalledVersions::getInstallPath(LGV_TZ_Lookup_Setup::PACKAGE);
375 $database = new LGV_TZ_Lookup_Database($settings['database'], $settings['user'], $settings['password'],
376 $settings['driver'], $settings['host'], $settings['port']);
377 require $package.'/src/TestLocations.php';
379 $failures = 0;
380 foreach ($test_locations_param_array as $case) {
381 $lng = $case['params']['lng']; $lat = $case['params']['lat'];
382 while ($lng < -180) { $lng += 360; } while ($lng > 180) { $lng -= 360; }
383 while ($lat < -90) { $lat += 180; } while ($lat > 90) { $lat -= 180; }
384 if ($lookup->get_tz($lng, $lat) !== $case['result']) { ++$failures; }
385 }
386 if ($failures) { throw new RuntimeException($failures.' known-location tests failed; the existing installation was retained.'); }
387 foreach (['update.php', 'Setup.php'] as $file) {
388 if (!copy(__DIR__.'/'.$file, $privateStage.'/tools/'.$file)) { throw new RuntimeException('Could not refresh updater code.'); }
389 }
390 if (!copy(dirname(__DIR__).'/update.sh', $privateStage.'/update.sh')) { throw new RuntimeException('Could not refresh the updater command.'); }
391 $source = file_get_contents(__FILE__);
392 $license = explode("/***************************************************************************************************************************/\n/**\n \\file", $source, 2)[0];
393 $relativePackage = substr($package, strlen($privateStage));
394 if (file_put_contents($publicStage.'/index.php', deployEndpointCode($private, $relativePackage, $license)) === false) {
395 throw new RuntimeException('Could not write the refreshed public entry point.');
396 }
397 foreach (['composer.phar', 'composer.sha256'] as $file) { if (is_file($privateStage.'/app/'.$file)) { unlink($privateStage.'/app/'.$file); } }
398 foreach (['composer-cache', 'composer-home'] as $dir) { if (is_dir($privateStage.'/app/'.$dir)) { deployRemoveTree($privateStage.'/app/'.$dir); } }
399 $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($privateStage.'/app', FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST);
400 foreach ($files as $file) {
401 if (!$file->isLink() && !chmod($file->getPathname(), $file->isDir() || ($file->getPerms() & 0111) ? 0755 : 0644)) {
402 throw new RuntimeException('Could not set refreshed code permissions.');
403 }
404 }
405 if (!chgrp($privateStage, $group) || !chgrp($privateStage.'/config.php', $group) ||
406 !chmod($privateStage, 0750) || !chmod($privateStage.'/config.php', 0640) ||
407 !chmod($privateStage.'/update.sh', 0755) || !chmod($publicStage, 0755) || !chmod($publicStage.'/index.php', 0644)) {
408 throw new RuntimeException('Could not set refreshed installation permissions.');
409 }
410 $metadata = $existing['metadata'];
411 $metadata['service_url'] = $serviceUrl;
412 $metadata['known_location_checks'] = count($test_locations_param_array);
413 if (file_put_contents($privateStage.'/installation.json', json_encode($metadata, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)) === false) {
414 throw new RuntimeException('Could not write refreshed installation metadata.');
415 }
416 foreach (['private', 'public'] as $key) {
417 if (!rename($state[$key], $state[$key.'_backup']) || !rename($state[$key.'_stage'], $state[$key])) {
418 throw new RuntimeException('Could not publish refreshed code; recovery will restore the prior installation.');
419 }
420 }
421 $state['published'] = true;
422 deploySave($work, $state);
424 $secretQuery = $existing['secret'] === '' ? '' : '&secret='.rawurlencode($existing['secret']);
425 echo 'Refreshed installed code; retained '.$count.' polygons; '.count($test_locations_param_array)." known locations passed.\n";
426 echo 'Public endpoint: '.$public.'/index.php'."\nPrivate configuration: ".$private.'/config.php'."\n";
427 echo 'Service URL: '.$serviceUrl."\n";
428 echo 'Example request: '.$serviceUrl.'?ll=-77.036543,38.895037'.$secretQuery."\n";
429 echo 'Test request: '.$serviceUrl.'?test'.$secretQuery."\n";
430}
431
432/***************************************************************************************************************************/
433/**
434 \brief This removes installer downloads, and rolls back only installation-owned resources on failure.
435 A completed deployment retains its database and files. The guard token authorizes removal of an incomplete
436 timezones table; an unrelated table or installation is never adopted. Failed cleanup retains recovery state.
437*/
438function deployCleanup(string $work): void {
439 foreach (['boundaries.zip', 'boundaries.json'] as $file) {
440 if (is_file($work.'/'.$file) && !unlink($work.'/'.$file)) { throw new RuntimeException('Could not remove downloaded '.$file); }
441 }
442 $file = $work.'/deployment.json';
443 if (!is_file($file)) { return; }
444 $state = json_decode(file_get_contents($file), true, 512, JSON_THROW_ON_ERROR);
445 if (!empty($state['refresh'])) { deployRefreshCleanup($work, $state); return; }
446 if (!empty($state['guard_created'])) {
447 $connection = deployConnection($state['settings']);
449 if (deployTableExists($connection, 'lgv_tz_install_guard')) {
450 $statement = $connection->prepare('SELECT token FROM lgv_tz_install_guard');
451 $statement->execute();
452 $token = $statement->fetchColumn();
453 $statement->closeCursor();
454 $statement = null;
455 if (!is_string($token) || !hash_equals($state['token'], $token)) {
456 throw new RuntimeException('Installation ownership differs; refusing to remove database tables.');
457 }
458 if (empty($state['published'])) { $connection->exec('DROP TABLE IF EXISTS timezones'); }
459 $connection->exec('DROP TABLE lgv_tz_install_guard');
460 } elseif (empty($state['published'])) {
461 throw new RuntimeException('Installation ownership record is missing; refusing to remove database tables.');
462 }
463 $state['guard_created'] = false;
464 deploySave($work, $state);
465 $connection = null;
466 }
467 if (empty($state['published']) && !empty($state['database_created'])) {
468 deployConnection($state['settings'], true)->exec('DROP DATABASE '.deployIdentifier($state['settings']['database'], $state['settings']['driver']));
469 $state['database_created'] = false;
470 deploySave($work, $state);
471 }
472 $paths = [$state['private_stage'], $state['public_stage']];
473 if (empty($state['published'])) { $paths = array_merge($paths, [$state['private'], $state['public']]); }
474 foreach ($paths as $path) {
475 if (!is_dir($path)) { continue; }
476 foreach ($state['empty_directories'] ?? [] as $key => $original) {
477 // An original destination not yet moved for publication was never owned by this installation.
478 if ($path === $state[$key] && deploySameDirectory($path, $original)) { continue 2; }
479 }
480 $marker = $path.'/.lgv-tz-install-owner';
481 if (!is_file($marker) || !hash_equals($state['token'], trim(file_get_contents($marker)))) {
482 throw new RuntimeException('Directory ownership differs; refusing to remove '.$path);
483 }
484 deployRemoveTree($path);
485 }
487 unlink($file);
488}
489
490/***************************************************************************************************************************/
491/**
492 \brief This gathers settings, loads and tests the Composer package, then publishes the endpoint.
493 The selected web directory must exist. New installations accept new or empty destinations.
494 Existing installer-owned directories can be refreshed without reloading boundaries;
495 private files must be outside the web document root. Existing timezones tables are refused before loading.
496 \throws Exception if setup, validation, or publication fails.
497*/
498function deployRun(string $work, bool $noSecret = false, $terminal = null, ?callable $install = null): void {
499 $started = hrtime(true);
500 foreach (['curl', 'zip', 'mbstring', 'ctype', 'pdo', 'posix'] as $extension) {
501 if (!extension_loaded($extension)) { throw new RuntimeException('The PHP '.$extension.' extension is required.'); }
502 }
503 if (!function_exists('proc_open')) { throw new RuntimeException('PHP proc_open is required.'); }
504 $terminal = $terminal ?? @fopen('/dev/tty', 'r+');
505 if (!is_resource($terminal)) { throw new RuntimeException('Run the installer from an interactive terminal.'); }
506 echo "LGV_TZ_Lookup server installation\n\n";
507 $driver = strtolower(deployAsk($terminal, 'Database driver (mysql or pgsql)', 'mysql'));
508 if (!in_array($driver, ['mysql', 'pgsql'], true) || !extension_loaded('pdo_'.$driver)) {
509 throw new RuntimeException('Choose mysql or pgsql, with its PHP PDO extension enabled.');
510 }
511 $settings = ['driver' => $driver, 'host' => deployAsk($terminal, 'Database host', 'localhost')];
512 $port = deployAsk($terminal, 'Database port', $driver === 'pgsql' ? '5432' : '3306');
513 if (!ctype_digit($port) || (int)$port < 1 || (int)$port > 65535) { throw new RuntimeException('Port must be between 1 and 65535.'); }
514 $settings['port'] = (int)$port;
515 $settings['database'] = deployAsk($terminal, 'Database name', 'tz_lookup');
516 $maxName = $driver === 'pgsql' ? 63 : 64;
517 if (!preg_match('/^[a-zA-Z0-9_-]{1,'.$maxName.'}$/D', $settings['database'])) {
518 throw new RuntimeException('Use a database name containing letters, numbers, underscores, or hyphens.');
519 }
520 $settings['user'] = deployAsk($terminal, 'Database user', $driver === 'pgsql' ? (getenv('USER') ?: get_current_user()) : 'root');
521 $settings['password'] = deployAsk($terminal, 'Database password (input hidden; empty is allowed)', '', true);
522 $settings['admin_database'] = $driver === 'pgsql' ? deployAsk($terminal, 'Administrative database (used if creating a database)', 'postgres') : '';
523 $create = strtolower(deployAsk($terminal, 'Create the database if it does not exist? (y/n)', 'y')) === 'y';
524 $webRoot = realpath(deployAsk($terminal, 'Existing web directory (filesystem path; parent of the new service)', getcwd()));
525 if (false === $webRoot || !is_dir($webRoot) || !is_writable($webRoot)) { throw new RuntimeException('The web directory must be an existing writable directory.'); }
526 $webUrl = deployAsk($terminal, 'Public URL of that directory (e.g. https://example.com/path)');
527 $endpoint = deployAsk($terminal, 'Service subdirectory (appended to the path and URL above)', 'timezone');
528 $serviceUrl = deployServiceUrl($webUrl, $endpoint);
529 $private = deployAsk($terminal, 'Private application directory (new or empty; outside the web root)', dirname($webRoot).'/lgv-tz-server');
530 $parent = realpath(dirname($private));
531 if (false === $parent || !is_writable($parent) || in_array(basename($private), ['.', '..', ''], true)) {
532 throw new RuntimeException('The private application directory needs an existing writable parent.');
533 }
534 $private = $parent.'/'.basename($private);
535 $sourceRoot = realpath(dirname(__DIR__));
536 if ($private === $sourceRoot || str_starts_with($private, $sourceRoot.'/')) {
537 throw new RuntimeException('Install private files outside the source checkout to avoid copying the installation into itself.');
538 }
539 if ($webRoot === '/' || $private === $webRoot || str_starts_with($private, $webRoot.'/')) {
540 throw new RuntimeException('The private application directory must be outside the web document root.');
541 }
542 $public = $webRoot.'/'.$endpoint;
543 $emptyDirectories = deployEmptyDirectories(['private' => $private, 'public' => $public]);
544 $existing = (file_exists($private) && !isset($emptyDirectories['private'])) ||
545 (file_exists($public) && !isset($emptyDirectories['public']))
546 ? deployExistingInstallation($private, $public, $settings) : null;
547 $groupName = deployAsk($terminal, 'Group that runs PHP (for private-file access)', posix_getgrgid(posix_getegid())['name']);
548 $group = posix_getgrnam($groupName);
549 if (false === $group) { throw new RuntimeException('The PHP group does not exist.'); }
550 $install = $install ?? [LGV_TZ_Lookup_Setup::class, 'install'];
551 if ($existing !== null) {
552 deployRefresh($work, $settings, $private, $public, $group['gid'], $serviceUrl, $existing, $install);
553 return;
554 }
555 $secret = !$noSecret && strtolower(deployAsk($terminal, 'Require a server secret? (y/n)', 'y')) !== 'n' ? bin2hex(random_bytes(32)) : '';
556 $token = bin2hex(random_bytes(32));
557 $privateStage = $parent.'/.lgv-tz-install-'.bin2hex(random_bytes(12));
558 $publicStage = $webRoot.'/.lgv-tz-install-'.bin2hex(random_bytes(12));
559 $state = ['settings' => $settings, 'token' => $token, 'private' => $private, 'public' => $public,
560 'private_stage' => $privateStage, 'public_stage' => $publicStage, 'database_created' => false,
561 'guard_created' => false, 'published' => false, 'empty_directories' => $emptyDirectories];
562 deploySave($work, $state);
563 foreach ([$privateStage, $publicStage] as $path) {
564 if (!mkdir($path, 0700) || false === file_put_contents($path.'/.lgv-tz-install-owner', $token)) {
565 throw new RuntimeException('Could not create installation staging directories.');
566 }
567 }
568 if (!chgrp($privateStage, $group['gid'])) { throw new RuntimeException('Could not assign the PHP group.'); }
570 catch (PDOException $error) {
571 $missing = $driver === 'pgsql' ? ($error->errorInfo[0] ?? '') === '08006' &&
572 str_contains($error->getMessage(), 'database "'.$settings['database'].'" does not exist')
573 : (int)($error->errorInfo[1] ?? 0) === 1049;
574 if (!$create || !$missing) { throw $error; }
576 $admin->exec('CREATE DATABASE '.deployIdentifier($settings['database'], $driver));
577 $state['database_created'] = true;
578 deploySave($work, $state);
579 $admin = null;
581 }
583 if (deployTableExists($connection, 'timezones') || deployTableExists($connection, 'lgv_tz_install_guard')) {
584 throw new RuntimeException('This database already contains timezone or installer tables; choose an empty database.');
585 }
586 $connection->exec('CREATE TABLE lgv_tz_install_guard (token VARCHAR(64) PRIMARY KEY)');
587 $statement = $connection->prepare('INSERT INTO lgv_tz_install_guard VALUES (?)');
588 $statement->execute([$token]);
589 $statement->closeCursor();
590 $state['guard_created'] = true;
591 deploySave($work, $state);
592 mkdir($privateStage.'/app', 0755);
593 $install($privateStage.'/app', $driver, dirname(__DIR__));
594 $package = Composer\InstalledVersions::getInstallPath(LGV_TZ_Lookup_Setup::PACKAGE);
595 echo "Downloading the latest timezone boundaries...\n";
597 $asset = $release['asset'];
598 LGV_TZ_Lookup_Setup::download($asset['browser_download_url'], $work.'/boundaries.zip');
599 $checksum = LGV_TZ_Lookup_Setup::verifyBoundaries($work.'/boundaries.zip', $asset);
600 LGV_TZ_Lookup_Setup::extract($work.'/boundaries.zip', $work.'/boundaries.json');
601 $database = new LGV_TZ_Lookup_Database($settings['database'], $settings['user'], $settings['password'], $driver, $settings['host'], $settings['port']);
602 echo 'Loading boundary release '.$release['version']."...\n";
603 $stream = fopen($work.'/boundaries.json', 'rb');
604 try { (new JsonStreamingParser\Parser($stream, new LGV_TZ_Lookup_Loader($database)))->parse(); }
605 finally { fclose($stream); unlink($work.'/boundaries.zip'); unlink($work.'/boundaries.json'); }
606 if ($driver === 'pgsql') { $connection->exec('ANALYZE timezones'); }
607 $count = (int)$connection->query('SELECT COUNT(*) FROM timezones')->fetchColumn();
608 if ($count === 0) { throw new RuntimeException('No polygons were loaded.'); }
609 require $package.'/src/TestLocations.php';
611 $failures = 0;
612 $testStart = hrtime(true);
613 foreach ($test_locations_param_array as $case) {
614 $lng = $case['params']['lng']; $lat = $case['params']['lat'];
615 while ($lng < -180) { $lng += 360; } while ($lng > 180) { $lng -= 360; }
616 while ($lat < -90) { $lat += 180; } while ($lat > 90) { $lat -= 180; }
617 if ($lookup->get_tz($lng, $lat) !== $case['result']) { ++$failures; echo 'FAIL '.$case['title']."\n"; }
618 }
619 if ($failures) { throw new RuntimeException($failures.' known-location tests failed; the service was not published.'); }
620 $lookupSeconds = (hrtime(true) - $testStart) / 1e9;
621 $source = file_get_contents(__FILE__);
622 $licenseHeader = explode("/***************************************************************************************************************************/\n/**\n \\file", $source, 2)[0];
623 $config = $licenseHeader;
624 foreach (['g_dbName' => $settings['database'], 'g_dbUserName' => $settings['user'], 'g_dbPassword' => $settings['password'],
625 'g_dbType' => $driver, 'g_dbHost' => $settings['host'], 'g_dbPort' => $settings['port'], 'g_server_secret' => $secret] as $key => $value) {
626 $config .= '$'.$key.' = '.var_export($value, true).";\n";
627 }
628 file_put_contents($privateStage.'/config.php', $config);
629 if (!chmod($privateStage.'/config.php', 0640) || !chgrp($privateStage.'/config.php', $group['gid'])) {
630 throw new RuntimeException('Could not set private configuration permissions for the PHP group.');
631 }
632 mkdir($privateStage.'/tools', 0755);
633 foreach (['update.php', 'Setup.php'] as $file) {
634 if (!copy(__DIR__.'/'.$file, $privateStage.'/tools/'.$file) || !chmod($privateStage.'/tools/'.$file, 0644)) {
635 throw new RuntimeException('Could not install the boundary updater.');
636 }
637 }
638 if (!copy(dirname(__DIR__).'/update.sh', $privateStage.'/update.sh') || !chmod($privateStage.'/update.sh', 0755)) {
639 throw new RuntimeException('Could not install the boundary update command.');
640 }
641 $relativePackage = substr($package, strlen($privateStage));
642 $endpointCode = deployEndpointCode($private, $relativePackage, $licenseHeader);
643 file_put_contents($publicStage.'/index.php', $endpointCode);
644 chmod($publicStage.'/index.php', 0644);
645 chmod($publicStage.'/.lgv-tz-install-owner', 0644);
646 foreach (['composer.phar', 'composer.sha256'] as $file) { unlink($privateStage.'/app/'.$file); }
647 foreach (['composer-cache', 'composer-home'] as $dir) { deployRemoveTree($privateStage.'/app/'.$dir); }
648 // Only code and manifests live below app. Make them readable through the private directory's selected PHP group.
649 $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($privateStage.'/app', FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST);
650 foreach ($files as $file) {
651 if ($file->isLink()) { continue; }
652 $mode = $file->isDir() || ($file->getPerms() & 0111) ? 0755 : 0644;
653 if (!chmod($file->getPathname(), $mode)) { throw new RuntimeException('Could not set installed code permissions.'); }
654 }
655 chmod($privateStage.'/app', 0755);
656 file_put_contents($privateStage.'/installation.json', json_encode(['release' => $release['version'], 'archive_sha256' => $checksum,
657 'polygons' => $count, 'driver' => $driver, 'known_location_checks' => count($test_locations_param_array),
658 'service_url' => $serviceUrl], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
659 chmod($privateStage, 0750);
660 chmod($publicStage, 0755);
662 $state['published'] = true;
663 deploySave($work, $state);
664 printf("\nInstalled %d polygons; %d/%d known locations passed (%.3f ms mean lookup).\n", $count, count($test_locations_param_array), count($test_locations_param_array), $lookupSeconds * 1000 / count($test_locations_param_array));
665 printf("Installation: %.2f s; PHP peak %.2f MiB.\n", (hrtime(true) - $started) / 1e9, memory_get_peak_usage() / 1048576);
666 echo 'Public endpoint: '.$public."/index.php\nPrivate configuration: ".$private."/config.php\n";
667 echo 'Service URL: '.$serviceUrl."\n";
668 echo 'Boundary updater: '.$private."/update.sh (use --check to report the latest release).\n";
669 echo $secret === '' ? "Server secret: disabled.\n" : 'Server secret: '.$secret."\n";
670 $secretQuery = $secret === '' ? '' : '&secret='.rawurlencode($secret);
671 echo 'Example request: '.$serviceUrl.'?ll=-77.036543,38.895037'.$secretQuery."\n";
672 echo 'Test request: '.$serviceUrl.'?test'.$secretQuery."\n";
673 echo "Boundary downloads deleted. The installed database is retained.\n";
674}
675
676if (realpath($_SERVER['SCRIPT_FILENAME'] ?? '') !== __FILE__) { return; }
677if (PHP_SAPI !== 'cli' || $argc < 3 || !in_array($argv[1], ['run', 'cleanup'], true) || !is_dir($argv[2])) {
678 fwrite(STDERR, "Run ./deploy.sh [--no-secret].\n"); exit(2);
679}
680if (function_exists('pcntl_async_signals')) {
681 pcntl_async_signals(true);
682 foreach ([SIGHUP, SIGINT, SIGTERM] as $signal) {
683 pcntl_signal($signal, static function(int $signal): void { throw new RuntimeException('Installation interrupted by signal '.$signal.'.'); });
684 }
685}
686try {
687 if ($argv[1] === 'cleanup') { deployCleanup($argv[2]); }
688 else { deployRun($argv[2], ($argv[3] ?? '') === '--no-secret'); }
689} catch (Throwable $error) {
690 fwrite(STDERR, 'Installation error: '.$error->getMessage().PHP_EOL); exit(1);
691}
$test_locations_param_array
This file has a list of long/lat pairs, along with the expected results, when presented to the server...
$failures
Definition benchmark.php:79
static extract(string $archive, string $destination)
Extract exactly one GeoJSON entry without using archive paths.
Definition Setup.php:107
static latestBoundaries(string $directory, ?callable $download=null)
Discover the latest full boundary release with oceans.
Definition Setup.php:42
static verifyBoundaries(string $archive, array $asset)
Verify the archive against the release's size and optional SHA-256 digest.
Definition Setup.php:61
static download(string $url, string $destination)
Download an HTTPS resource to a local file.
Definition Setup.php:76
const PACKAGE
The package installed by both command-line tools.
Definition Setup.php:38
$connection
Definition composer.php:31
$database
Definition composer.php:52
$lookup
Definition composer.php:54
$quote
$driver
Definition databases.php:38
$dsn
$port
$name
if($driver==='pgsql') $admin
$config
Definition server.php:43
if(!defined( '__CONFIG_FILE_'))
Definition index.php:51
$directory
Definition demo.php:107
$process
Definition demo.php:113
$source
Definition demo.php:67
$error
Definition demo.php:116
$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
deployLock(PDO $connection)
This prevents two installers from loading the same database concurrently.
Definition deploy.php:69
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
deployRefreshCleanup(string $work, array $state)
Restore prior directories after an interrupted refresh, or remove backups after publication....
Definition deploy.php:305
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
deployAsk($terminal, string $label, string $default='', bool $hidden=false)
This asks for a setting, with a default and optional password hiding.
Definition deploy.php:92
deployEmptyDirectoryCleanup(array $state)
Restore the original empty directories on failure, or remove their empty backups after success.
Definition deploy.php:214
deployEndpointCode(string $private, string $relativePackage, string $licenseHeader)
Generate the public entry point with the chosen private configuration and package paths.
Definition deploy.php:295
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