@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.)
hq.recaptime.dev/wiki/Phorge
phorge
phabricator
1<?php
2
3abstract class PhabricatorAphlictManagementWorkflow
4 extends PhabricatorManagementWorkflow {
5
6 private $debug = false;
7 private $configData;
8 private $configPath;
9
10 final protected function setDebug($debug) {
11 $this->debug = $debug;
12 return $this;
13 }
14
15 protected function getLaunchArguments() {
16 return array(
17 array(
18 'name' => 'config',
19 'param' => 'file',
20 'help' => pht(
21 'Use a specific configuration file instead of the default '.
22 'configuration.'),
23 ),
24 );
25 }
26
27 protected function parseLaunchArguments(PhutilArgumentParser $args) {
28 $config_file = $args->getArg('config');
29 if ($config_file) {
30 $full_path = Filesystem::resolvePath($config_file);
31 $show_path = $full_path;
32 } else {
33 $root = dirname(phutil_get_library_root('phorge'));
34
35 $try = array(
36 'conf/aphlict/aphlict.custom.json',
37 'conf/aphlict/aphlict.default.json',
38 );
39
40 foreach ($try as $config) {
41 $full_path = $root.'/'.$config;
42 $show_path = '<phorge>/'.$config;
43 if (Filesystem::pathExists($full_path)) {
44 break;
45 }
46 }
47 }
48
49 echo tsprintf(
50 "%s\n",
51 pht(
52 'Reading configuration from: %s',
53 $show_path));
54
55 try {
56 $data = Filesystem::readFile($full_path);
57 } catch (Exception $ex) {
58 throw new PhutilArgumentUsageException(
59 pht(
60 'Failed to read configuration file. %s',
61 $ex->getMessage()));
62 }
63
64 try {
65 $data = phutil_json_decode($data);
66 } catch (Exception $ex) {
67 throw new PhutilArgumentUsageException(
68 pht(
69 'Configuration file is not properly formatted JSON. %s',
70 $ex->getMessage()));
71 }
72
73 try {
74 PhutilTypeSpec::checkMap(
75 $data,
76 array(
77 'servers' => 'list<wild>',
78 'logs' => 'optional list<wild>',
79 'cluster' => 'optional list<wild>',
80 'pidfile' => 'string',
81 'memory.hint' => 'optional int',
82 ));
83 } catch (Exception $ex) {
84 throw new PhutilArgumentUsageException(
85 pht(
86 'Configuration file has improper configuration keys at top '.
87 'level. %s',
88 $ex->getMessage()));
89 }
90
91 $servers = $data['servers'];
92 $has_client = false;
93 $has_admin = false;
94 $port_map = array();
95 foreach ($servers as $index => $server) {
96 PhutilTypeSpec::checkMap(
97 $server,
98 array(
99 'type' => 'string',
100 'port' => 'int',
101 'listen' => 'optional string|null',
102 'ssl.key' => 'optional string|null',
103 'ssl.cert' => 'optional string|null',
104 'ssl.chain' => 'optional string|null',
105 ));
106
107 $port = $server['port'];
108 if (!isset($port_map[$port])) {
109 $port_map[$port] = $index;
110 } else {
111 throw new PhutilArgumentUsageException(
112 pht(
113 'Two servers (at indexes "%s" and "%s") both bind to the same '.
114 'port ("%s"). Each server must bind to a unique port.',
115 $port_map[$port],
116 $index,
117 $port));
118 }
119
120 $type = $server['type'];
121 switch ($type) {
122 case 'admin':
123 $has_admin = true;
124 break;
125 case 'client':
126 $has_client = true;
127 break;
128 default:
129 throw new PhutilArgumentUsageException(
130 pht(
131 'A specified server (at index "%s", on port "%s") has an '.
132 'invalid type ("%s"). Valid types are: admin, client.',
133 $index,
134 $port,
135 $type));
136 }
137
138 $ssl_key = idx($server, 'ssl.key');
139 $ssl_cert = idx($server, 'ssl.cert');
140 if (($ssl_key && !$ssl_cert) || ($ssl_cert && !$ssl_key)) {
141 throw new PhutilArgumentUsageException(
142 pht(
143 'A specified server (at index "%s", on port "%s") specifies '.
144 'only one of "%s" and "%s". Each server must specify neither '.
145 '(to disable SSL) or specify both (to enable it).',
146 $index,
147 $port,
148 'ssl.key',
149 'ssl.cert'));
150 }
151
152 $ssl_chain = idx($server, 'ssl.chain');
153 if ($ssl_chain && (!$ssl_key && !$ssl_cert)) {
154 throw new PhutilArgumentUsageException(
155 pht(
156 'A specified server (at index "%s", on port "%s") specifies '.
157 'a value for "%s", but no value for "%s" or "%s". Servers '.
158 'should only provide an SSL chain if they also provide an SSL '.
159 'key and SSL certificate.',
160 $index,
161 $port,
162 'ssl.chain',
163 'ssl.key',
164 'ssl.cert'));
165 }
166 }
167
168 if (!$servers) {
169 throw new PhutilArgumentUsageException(
170 pht(
171 'Configuration file does not specify any servers. This service '.
172 'will not be able to interact with the outside world if it does '.
173 'not listen on any ports. You must specify at least one "%s" '.
174 'server and at least one "%s" server.',
175 'admin',
176 'client'));
177 }
178
179 if (!$has_client) {
180 throw new PhutilArgumentUsageException(
181 pht(
182 'Configuration file does not specify any client servers. This '.
183 'service will be unable to transmit any notifications without a '.
184 'client server. You must specify at least one server with '.
185 'type "%s".',
186 'client'));
187 }
188
189 if (!$has_admin) {
190 throw new PhutilArgumentUsageException(
191 pht(
192 'Configuration file does not specify any administrative '.
193 'servers. This service will be unable to receive messages. '.
194 'You must specify at least one server with type "%s".',
195 'admin'));
196 }
197
198 $logs = idx($data, 'logs', array());
199 foreach ($logs as $index => $log) {
200 PhutilTypeSpec::checkMap(
201 $log,
202 array(
203 'path' => 'string',
204 ));
205
206 $path = $log['path'];
207
208 try {
209 $dir = dirname($path);
210 if (!Filesystem::pathExists($dir)) {
211 Filesystem::createDirectory($dir, 0755, true);
212 }
213 } catch (FilesystemException $ex) {
214 throw new PhutilArgumentUsageException(
215 pht(
216 'Failed to create directory "%s" for specified log file (with '.
217 'index "%s"). You should manually create this directory or '.
218 'choose a different logfile location. %s',
219 $dir,
220 $index,
221 $ex->getMessage()));
222 }
223 }
224
225 $peer_map = array();
226
227 $cluster = idx($data, 'cluster', array());
228 foreach ($cluster as $index => $peer) {
229 PhutilTypeSpec::checkMap(
230 $peer,
231 array(
232 'host' => 'string',
233 'port' => 'int',
234 'protocol' => 'string',
235 ));
236
237 $host = $peer['host'];
238 $port = $peer['port'];
239 $protocol = $peer['protocol'];
240
241 switch ($protocol) {
242 case 'http':
243 case 'https':
244 break;
245 default:
246 throw new PhutilArgumentUsageException(
247 pht(
248 'Configuration file specifies cluster peer ("%s", at index '.
249 '"%s") with an invalid protocol, "%s". Valid protocols are '.
250 '"%s" or "%s".',
251 $host,
252 $index,
253 $protocol,
254 'http',
255 'https'));
256 }
257
258 $peer_key = "{$host}:{$port}";
259 if (!isset($peer_map[$peer_key])) {
260 $peer_map[$peer_key] = $index;
261 } else {
262 throw new PhutilArgumentUsageException(
263 pht(
264 'Configuration file specifies cluster peer "%s" more than '.
265 'once (at indexes "%s" and "%s"). Each peer must have a '.
266 'unique host and port combination.',
267 $peer_key,
268 $peer_map[$peer_key],
269 $index));
270 }
271 }
272
273 $this->configData = $data;
274 $this->configPath = $full_path;
275
276 $pid_path = $this->getPIDPath();
277 try {
278 $dir = dirname($pid_path);
279 if (!Filesystem::pathExists($dir)) {
280 Filesystem::createDirectory($dir, 0755, true);
281 }
282 } catch (FilesystemException $ex) {
283 throw new PhutilArgumentUsageException(
284 pht(
285 'Failed to create directory "%s" for specified PID file. You '.
286 'should manually create this directory or choose a different '.
287 'PID file location. %s',
288 $dir,
289 $ex->getMessage()));
290 }
291 }
292
293 final public function getPIDPath() {
294 return $this->configData['pidfile'];
295 }
296
297 final public function getPID() {
298 $pid = null;
299 if (Filesystem::pathExists($this->getPIDPath())) {
300 $pid = (int)Filesystem::readFile($this->getPIDPath());
301 }
302 return $pid;
303 }
304
305 final public function cleanup($signo = null) {
306 global $g_future;
307 if ($g_future) {
308 $g_future->resolveKill();
309 $g_future = null;
310 }
311
312 Filesystem::remove($this->getPIDPath());
313
314 if ($signo !== null) {
315 $signame = phutil_get_signal_name($signo);
316 error_log("Caught signal {$signame}, exiting.");
317 }
318
319 exit(1);
320 }
321
322 public static function requireExtensions() {
323 self::mustHaveExtension('pcntl');
324 self::mustHaveExtension('posix');
325 }
326
327 private static function mustHaveExtension($ext) {
328 if (!extension_loaded($ext)) {
329 echo pht(
330 "ERROR: The PHP extension '%s' is not installed. You must ".
331 "install it to run Aphlict on this machine.",
332 $ext)."\n";
333 exit(1);
334 }
335
336 $extension = new ReflectionExtension($ext);
337 foreach ($extension->getFunctions() as $function) {
338 $function = $function->name;
339 if (!function_exists($function)) {
340 echo pht(
341 'ERROR: The PHP function %s is disabled. You must '.
342 'enable it to run Aphlict on this machine.',
343 $function.'()')."\n";
344 exit(1);
345 }
346 }
347 }
348
349 final protected function willLaunch() {
350 $console = PhutilConsole::getConsole();
351
352 $pid = $this->getPID();
353 if ($pid) {
354 throw new PhutilArgumentUsageException(
355 pht(
356 'Unable to start notifications server because it is already '.
357 'running. Use `%s` to restart it.',
358 'aphlict restart'));
359 }
360
361 if (posix_getuid() == 0) {
362 throw new PhutilArgumentUsageException(
363 pht('The notification server should not be run as root.'));
364 }
365
366 // Make sure we can write to the PID file.
367 if (!$this->debug) {
368 Filesystem::writeFile($this->getPIDPath(), '');
369 }
370
371 // First, start the server in configuration test mode with --test. This
372 // will let us error explicitly if there are missing modules, before we
373 // fork and lose access to the console.
374 $test_argv = $this->getServerArgv();
375 $test_argv[] = '--test=true';
376
377
378 execx('%C', $this->getStartCommand($test_argv));
379 }
380
381 private function getServerArgv() {
382 $server_argv = array();
383 $server_argv[] = '--config='.$this->configPath;
384 return $server_argv;
385 }
386
387 final protected function launch() {
388 $console = PhutilConsole::getConsole();
389
390 if ($this->debug) {
391 $console->writeOut(
392 "%s\n",
393 pht('Starting Aphlict server in foreground...'));
394 } else {
395 Filesystem::writeFile($this->getPIDPath(), (string)getmypid());
396 }
397
398 $command = $this->getStartCommand($this->getServerArgv());
399
400 if (!$this->debug) {
401 declare(ticks = 1);
402 pcntl_signal(SIGINT, array($this, 'cleanup'));
403 pcntl_signal(SIGTERM, array($this, 'cleanup'));
404 }
405 register_shutdown_function(array($this, 'cleanup'));
406
407 if ($this->debug) {
408 $console->writeOut(
409 "%s\n\n $ %s\n\n",
410 pht('Launching server:'),
411 $command);
412
413 $err = phutil_passthru('%C', $command);
414 $console->writeOut(">>> %s\n", pht('Server exited!'));
415 exit($err);
416 } else {
417 while (true) {
418 global $g_future;
419 $g_future = new ExecFuture('exec %C', $command);
420
421 // Discard all output the subprocess produces: it writes to the log on
422 // disk, so we don't need to send the output anywhere and can just
423 // throw it away.
424 $g_future
425 ->setStdoutSizeLimit(0)
426 ->setStderrSizeLimit(0);
427
428 $g_future->resolve();
429
430 // If the server exited, wait a couple of seconds and restart it.
431 unset($g_future);
432 sleep(2);
433 }
434 }
435 }
436
437
438/* -( Commands )----------------------------------------------------------- */
439
440
441 final protected function executeStartCommand() {
442 $console = PhutilConsole::getConsole();
443 $this->willLaunch();
444
445 $log = $this->getOverseerLogPath();
446 if ($log !== null) {
447 echo tsprintf(
448 "%s\n",
449 pht(
450 'Writing logs to: %s',
451 $log));
452 }
453
454 $pid = pcntl_fork();
455 if ($pid < 0) {
456 throw new Exception(
457 pht(
458 'Failed to %s!',
459 'fork()'));
460 } else if ($pid) {
461 $console->writeErr("%s\n", pht('Aphlict Server started.'));
462 exit(0);
463 }
464
465 // Redirect process errors to the error log. If we do not do this, any
466 // error the `aphlict` process itself encounters vanishes into thin air.
467 if ($log !== null) {
468 ini_set('error_log', $log);
469 }
470
471 // When we fork, the child process will inherit its parent's set of open
472 // file descriptors. If the parent process of bin/aphlict is waiting for
473 // bin/aphlict's file descriptors to close, it will be stuck waiting on
474 // the daemonized process. (This happens if e.g. bin/aphlict is started
475 // in another script using passthru().)
476 fclose(STDOUT);
477 fclose(STDERR);
478
479 $this->launch();
480 return 0;
481 }
482
483
484 final protected function executeStopCommand() {
485 $console = PhutilConsole::getConsole();
486
487 $pid = $this->getPID();
488 if (!$pid) {
489 $console->writeErr("%s\n", pht('Aphlict is not running.'));
490 return 0;
491 }
492
493 $console->writeErr("%s\n", pht('Stopping Aphlict Server (%s)...', $pid));
494 posix_kill($pid, SIGINT);
495
496 $start = time();
497 do {
498 if (!PhabricatorDaemonReference::isProcessRunning($pid)) {
499 $console->writeOut(
500 "%s\n",
501 pht('Aphlict Server (%s) exited normally.', $pid));
502 $pid = null;
503 break;
504 }
505 usleep(100000);
506 } while (time() < $start + 5);
507
508 if ($pid) {
509 $console->writeErr("%s\n", pht('Sending %s a SIGKILL.', $pid));
510 posix_kill($pid, SIGKILL);
511 unset($pid);
512 }
513
514 Filesystem::remove($this->getPIDPath());
515 return 0;
516 }
517
518 private function getNodeBinary() {
519 if (Filesystem::binaryExists('nodejs')) {
520 return 'nodejs';
521 }
522
523 if (Filesystem::binaryExists('node')) {
524 return 'node';
525 }
526
527 throw new PhutilArgumentUsageException(
528 pht(
529 'No `%s` or `%s` binary was found in %s. You must install '.
530 'Node.js to start the Aphlict server.',
531 'nodejs',
532 'node',
533 '$PATH'));
534 }
535
536 private function getAphlictScriptPath() {
537 $root = dirname(phutil_get_library_root('phabricator'));
538 return $root.'/support/aphlict/server/aphlict_server.js';
539 }
540
541 private function getNodeArgv() {
542 $argv = array();
543
544 $hint = idx($this->configData, 'memory.hint');
545 $hint = nonempty($hint, 256);
546
547 $argv[] = sprintf('--max-old-space-size=%d', $hint);
548
549 return $argv;
550 }
551
552 private function getStartCommand(array $server_argv) {
553 $launch_argv = array();
554
555 if ($this->debug) {
556 $launch_argv[] = '--debug=1';
557 }
558
559 return csprintf(
560 '%R %Ls -- %s %Ls %Ls',
561 $this->getNodeBinary(),
562 $this->getNodeArgv(),
563 $this->getAphlictScriptPath(),
564 $launch_argv,
565 $server_argv);
566 }
567
568 private function getOverseerLogPath() {
569 // For now, just return the first log. We could refine this eventually.
570 $logs = idx($this->configData, 'logs', array());
571
572 foreach ($logs as $log) {
573 return $log['path'];
574 }
575
576 return null;
577 }
578
579}